release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run

本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
AskaEth committed 2026-11-11 21:41:38 +08:00
commit a5d1dec495
1580 files changed
+320938

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
# 打包与发布工程(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`](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`](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–§16(契约入口;§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_gpu_shot.py` | GPU 截图证据判定(M249):`[shot] … renderer=wgpu` + `Selected: AdapterInfo` 的 `device_type ∉ {Cpu, Other}` + PNG 非空/尺寸对账/非空白,逐条点名;`--expect-refuse` 判拒绝降级出图;`--self-test` 跑 `tests/gpu_shot_vectors.json` 的 19 条向量(脚手架 `tests/gpu_shot_selftest.py`)。CI 接线见下一行(M253),口径与能证/不能证见 `docs/gpu-evidence.md` | ≤400 |
| `.github/workflows/gpu-evidence.yml` | GPU 证据门禁接线(M253):`selftest` **无条件**跑 `--self-test` + 三个退出码 0/1/2;`probe` 探测可用产物与硬件适配器;`evidence` 只在 `needs.probe.outputs.available == 'true'` 时跑真判据 —— **无 GPU / 无产物时它整块显示 skipped(不是绿色 ✓)**,原因写进 job summary 并打 `::warning::`(跳过 ≠ 通过)。新开文件而不是并进 `guards.yml`:那份已 399/400 行,`release-build.yml` 正好 400。**dev 线复用**(r1 P2-1):`dev-guards.yml` 里 `uses:` 同一份,dev 上只落 `selftest` 那一半 | ≤400 |
| `packaging/tests/gpu_shot_ci_selftest.py` | 上一条的**接线自检**(M253,r1+r2 返工):10 条判据 —— ① 五步按 `id:` 必须在场且无条件(`vectors`/`exitcodes`/`wiring`/`detect`/`judge`,`exitcodes` 还得真断言 0/1/2);② 自测步必须**指向本脚本且是单条命令**(`|| echo 先忽略` 无从下手);③ **解析 `if:` 表达式求值**判「判定挂门」(`available` 取 `'true'`/`'false'`/`''` 必须真/假/假,认不出即红);④ 两种调用形态都在场且产物来自 `needs.probe.outputs.*`;⑤ **判据步真跑两次**(正例必须 0、CPU 档必须非 0,`bash -e` 与裸 bash 两种模式);⑥ 跳过路径**实跑 7 种输入**;⑦ `continue-on-error` 按 YAML 布尔语义判;⑧ 吞码标记表(兜底);⑨ `probe.outputs.*` 必须逐个绑到 `steps.detect.outputs.*`;⑩ `on:` 必须同时有 `push`(含 `main`)/`pull_request`/`workflow_call`。外加 **33 条变异向量**(`tests/gpu_shot_ci_vectors.json`:r1 复审表 20 条 + 1 条补充 + r2 复审 12 条)逐条必须当场红。**不覆盖**:不自守、载体依赖:整个 `gpu-evidence.yml` 被删掉/替换后,**没有任何东西会跑这个自检** —— 守门人住在被守对象自己的文件里(`guards.yml` 已经 399/400 行,塞不进去)。求值器与 GitHub 语义的差异、判据 4 的「合法调用替换」。**自 2026-11-11 起停止加固**,扩项须由人类新开 mission | ≤400 |
| `packaging/tests/gpu_shot_ci_expr.py` | 上面判据 ③ 的**小求值器**:给 `needs.probe.outputs.available` 取 `'true'`/`'false'`/`''`,门必须分别判真/假/假 —— 门还挂不挂由**语义**决定,不由子串决定。只认 `==`/`!=`/`&&`/`||`/括号/字符串/`needs.<job>.outputs.<名>`/`true|false|null`/四个无参函数;`!` 与其余写法一律抛错(调用方按红处理)。不是 GitHub 语义的替身,是它的保守子集 | ≤400 |
| `packaging/tests/gpu_shot_ci_probe.py` | 上面判据 ⑤⑥ 的**执行脚手架**:把 `detect` / `judge` 两步的 `run:` 原文落盘、用桩 env 起 bash。`detect` 跑 7 种输入,断言 `$GITHUB_OUTPUT` 的 `available` / summary 文案 / `::warning::` / 三条「点了名却找不到 ⇒ 必须红」;`judge` 跑两次(正例必须 0、CPU 档必须非 0,两种 bash 模式),一次封掉 `\|\| echo` / `; :` / 管道 / `if !` / `true # 原行` 整类「绿而没判」。找不到能跑的 bash 即判红(不降级) | ≤400 |
| `packaging/tests/gpu_shot_ci_vectors.json` | 接线自检的变异向量:**33 条**,按**解析后的结构**注入(`step_delete`/`job_delete`/`step_set`/`job_set`/`path_set`/`trigger_set`/`run_append`/`run_replace`,`also` 可叠加),每条写明应红的判据名;来源见文件里的 `_note` | ≤400 |
| `packaging/tests/gpu_shot_vectors.json` | `check_gpu_shot.py --self-test` 的向量:19 条(红 16 / 绿 3),每条给出**期望的失败判据清单**,数据与代码分开 | ≤400 |
| `packaging/tests/gpu_shot_selftest.py` | 上面那 19 条向量的执行器(按真实行格式手拼日志 + 用 `check_screenshot.encode_png` 现造 PNG):判据脚本自己守 ≤400 行,脚手架放这里 | ≤400 |
| `packaging/check_publish_slots.py` | 发布名单守卫(M164/M184):名单 ⇄ 产物两个方向 + `mode: ci` 槽位无产物的警告;`--self-test` 注入 11 条回归 | ≤400 |
| `packaging/check_pe_subsystem.py` | PE 子系统守卫(M196):**读产物 PE 头的 `Subsystem`**(GUI 必须 2 / CLI 必须 3),非 2 即红;`--self-test` 注入 8 条回归(红 6 / 绿 2)(见 §15) | ≤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/check_test_key_off.py` | 测试公钥守门(M190 r3):把公开的测试公钥并进信任表的那条 feature 只许出现在 `crates/core/Cargo.toml` 自己的声明与 `crates/presenter/Cargo.toml` 的 `[dev-dependencies]` 段内,其余(workflow / 打包脚本 / 根 `Cargo.toml` / `.cargo/config*.toml` / presenter 的 `[dependencies]`)出现即红;`--self-test` 注入 7 条向量(5 红 / 2 绿)+ 基线;其中一条专治「core 的声明里偷偷带子 feature」(`update-test-key = ["update-test-source"]` ⇒ 红) | ≤400 |
| `packaging/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 |
| `packaging/check_signing_ready.py` | 签名就绪度播报(M255):**只读**、零第三方依赖,三行读数 —— ① `PRODUCTION` 表空不空(解析 `PINNED-KEYS` 标记之间)② 环境里有没有钥(`ULL_MANIFEST_SIGNING_KEY` / `--key`,顺手对账「这把钥在不在表里」,**种子一个字节都不打印**)③ 最近一次发布的 `.sig`/清单痕迹(取不到写「未知」,不写「无」)。退出码 `0` 就绪 / `1` 未就绪(打印「这会让已安装用户永远验不过(fail-closed)」+ 一行修复命令)/ `2` 用法错(含标记丢了 —— 那时形不成判断) | ≤400 |
| `packaging/tests/signing_ready_selftest.py` | 上一条的判据自测(M255):表空必 `exit 1` 且三行都打印 / **副本**夹一条假 `PinnedKey` 变 `exit 0`(不碰真实 `trust.rs`)+ 不泄种子 / ② 对账 / ③ 有-无-未知(含 `.sig` 子串假读数)/ **`--key` 读不了 = 2**(不许 traceback + 1)/ **非 main ref 的 ② 写「未判定」不写「没配」** / 输出里的 `§N` 必须是 runbook 真实章节 / `--line` 写 summary / **两个方向的变异重放**(「退出码永远 0」⇒ 判据①必须红;「永远 1」⇒ 判据②必须红)。不覆盖:整个 workflow 被删后没人跑它(与 `gpu_shot_ci_selftest.py` 同一条边界) | ≤400 |
| `packaging/tests/signing_ready_wiring_selftest.py` | 上上条的**接线**自测(M255 r1 分出,只为 400 行红线):没有别的 workflow `uses:` 它 / 全文无 error 级注解 / 就绪度那步 `rc=$?` 后 `exit 0` / 文件头两句口径都在 / **生产种子只在 `refs/heads/main` 注入**(注入行必须是 `github.ref == 'refs/heads/main' && secrets.…` 条件式;`on:` 块里不许出现 `pull_request`;非 main 必须给 `ULL_SIGNING_KEY_WITHHELD` 原因串)/ **文档指针存在性**(`signing-runbook.md` 与 `signing.md` 里的 `packaging/…`、`crates/…`、`.github/…` 路径必须真在仓库里)。三处变异重放:去掉 ref 门 / 加回 `pull_request` / 改坏文档路径 ⇒ 对应用例必须红 | ≤400 |
| `.github/workflows/signing-status.yml` | 就绪度**播报**接线(M255):把三行搬进 job summary。**不是门禁**:未就绪用 `::warning::`、就绪度那步**恒 `exit 0`**、**不应被设为 required**、刻意不声明 `workflow_call:`(别人没法 `uses:` 它,接线自检另外扫全仓防「顺手接进 dev 线」)。**安全边界(r1 P2-1)**:`ULL_MANIFEST_SIGNING_KEY` 只在 `refs/heads/main` 上注入(条件式 env),且**没有 `pull_request` 触发** —— 仓库内分支的 PR 拿得到 Secret 而 CI 跑的是 PR 自己的代码,种子进过那种运行等于交给任何能推分支的人;非 main ref 由 `ULL_SIGNING_KEY_WITHHELD` 说明原因,于是 ② 读「未判定」不是「没配」。同 job 另有两步跑自测(会红 = 尺子坏了,与「未就绪」分开写)。新开文件而不是并进 `guards.yml`:那份已 399/400 行 | ≤400 |
| `packaging/docs/signing-runbook.md` | **人类照做**的签名手册(M255):现状读数 → 5 步下单清单(生成钥 / Secret 名字逐字 / 贴 `PinnedKey` / 发带 `.sig` 的 Release / `--verify-pinned` 回验)→ 每步判据 → 11 条已知坑 → 发布后回验 → 播报≠门禁 → **没验的部分**(Authenticode、托管签名、真机更新端到端、本手册命令未真跑)。不含任何真实密钥,全是占位符 | ≤400 |
| `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 |
| `packaging/docs/linux-evidence.md` | Linux 槽位的证据账(M184):现有证据盘点、WSLg 真机实测输出、xvfb 复跑、CI 增量估算、未验清单 | ≤400 |
| `packaging/docs/gpu-evidence.md` | GPU 截图证据账(M249 + §8 CI 接线 M253:无 GPU 显式跳过、跳过 ≠ 通过、21 条接线变异):判据逐条「抓得住什么/抓不住什么」、本机实跑退出码、**能证**(字形栅格与边缘 AA / 面板 α 与玻璃 / 两后端一致性)与**不能证**(首帧与帧率 / 整窗 α 与圆角观感,走 DWM 任何截图都拍不到)、历史 `--diff` 基线永久不可复核 | ≤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-e2e` job(**只在 Windows**:夹具的「真形状」是 Windows 裸 `.exe` +
`portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。
**不验**真 GitHub 下载、GUI 点击与重启替换(需人工)。
## 15. 产物 PE 子系统(M196:GUI 必须 `WINDOWS_GUI`=2)
群友 2026-11-08 实测发布构建「运行时会带一个控制台窗口在底下」。根因:产物 PE 头的 `Subsystem` 仍是
CUI(3),`grep -rn windows_subsystem crates/` 当时零命中。同一个修复 2026-11-01 做过一次(`37d3039`),
但只改了 `crates/app/src/main.rs`——那棵树自 M118 起被 workspace `exclude`、不进发布矩阵,所以那一笔
**从未到达产物**。教训不是「忘了写属性」,而是没有一步去看产物本身。
- 修在哪:`crates/app-iced/src/main.rs` 顶上的 `#![cfg_attr(windows, windows_subsystem = "windows")]`。
PE 子系统是**每个 bin 目标**的属性,`cfg_attr` 是它的原生长相;不放 `build.rs` 是因为那里要绕
`cargo:rustc-link-arg` 间接改链接命令行,且会把「这条 bin 是 GUI」挪到看不见 `[[bin]]` 声明的地方。
- 代价与补救:GUI 子系统进程**不继承**父进程控制台,stdout/stderr 在 cmd 里是 0 号无效句柄。所以
`main()` 第一步调 `AttachConsole(ATTACH_PARENT_PROCESS)`(`crates/app-iced/src/platform/win32/console.rs`,
四种启动情形的实测表在该文件顶部):重定向/管道两个句柄本来就可用(一个字节都不动)、cmd 里直接跑
附着后输出落进控制台、双击时附着失败且**绝不新建窗口**。
- 守门:`python3 packaging/check_pe_subsystem.py --gui <exe> --cli <exe>`,读 `MZ`→`e_lfanew`(`0x3C`)→
`PE\0\0`→COFF 头→可选头 `+0x44` 的 2 个字节,打印**文件偏移**(release 产物实测:`Subsystem=2
(WINDOWS_GUI) @ 文件偏移 0xDC`,`e_lfanew=0x80`、PE32+)。两向断言:GUI 产物必须 2,CLI 产物
`ull.exe` 必须仍是 3(CONSOLE)——反方向不守时,把属性贴错 crate 会被漏掉,而 `ull --version | …`
会静默丢输出。
- 接在哪:`.github/workflows/release-build.yml`(发布侧,两个 Windows 槽位各跑一次)与
`.github/workflows/ci.yml` 的 `build` job(PR 阶段就红,不必等到 tag)。
- 抓不住(如实记):只看那 4 个字节,不看 GUI 双击的实际观感——「不再多一个黑框」在本仓库的验证环境
(RDP / 无干净无控制台桌面)只能由 PE 头 + 进程/窗口枚举间接证明,人眼验收归真机。
- **既无控制台也不重定向的宿主:输出照旧无处可去,退出码不变**(M196 r2 实测,反驳 r1 复审 P2-3 的
「退出码会变成 `EXIT_INTERNAL`(2)」——那条是**纯推理、无实测**)。两个**近似**形态 × 两条命令,
`child_exit_code` 全是 **0**:① 父进程有 std 句柄、子进程不继承(`dwFlags=0` + `bInheritHandles=FALSE`);
② 父进程 `FreeConsole()` 后把 `in/out/err` 三个 std 句柄全置 0,再起子进程(子进程侧实测
`stdin/stdout/stderr = 0x0`、`AttachConsole` 返回 0 / `GetLastError=6`)。原因在 Rust std 的 Windows
实现:句柄取不到时 `Write`/`flush` 直接返回 `Ok`(独立探针实测 `SetStdHandle(out)` = `0x0` / `0x1234` /
`(HANDLE)-1` 三种取值,`write=Ok(())`、`flush=Ok(())` 全成立),所以 `crates/cli/src/runner.rs` 那条
「写失败 ⇒ `EXIT_INTERNAL`」的分支不会被触发。结论:本 diff **没有**给 CLI 退出码引入可观察差异;
真正的差异只有「没控制台就没人看得见输出」这件事本身。
**边界(别把上面读成「真服务也测过」)**:①② 都只是**近似**形态——真 SCM 服务与计划任务(session 0、
no window station)、`start /b`、Windows Terminal / ConPTY 全部**未测**;对它们的结论只能靠「句柄无效
时 std 写返回 `Ok`」这条机制外推,不是在那些形态里亲测。第三方交叉(**复审的读数,不是我跑的**):
r1 复审在 r2 用更干净的形态(父 `FreeConsole()` + 三句柄置 0 + 子进程
`DETACHED_PROCESS | dwFlags=0 | bInheritHandles=FALSE`)独立复现,产品二进制 `--version` /
`instances list` / `--help` 全 0(`logs list` 是 1,但重定向跑同一条也是 1 ⇒ 用户错误,不是写失败)。
想让这种情形有专属退出码/文案,得在 CLI 侧显式判一次能力(`platform::win32::console` 的
`output_visible()` 就是现成判据),那在 `crates/cli/**`、不在 M196 的 scope 内——这里只如实记录形态。
## 16. 产物署名与清单签名(M190)
**编号来历(M203)**:本节原先也编作 §14,与 §14「更新链路的测试通道(M169)」撞号。撞号按「谁后插进来谁让位」
处理:M169 的 §14 与 M196 的 §15 都被仓库里的引用钉着(`packaging/check_test_source_off.py` 引 §14;
`.github/workflows/release-build.yml` 与 `crates/app-iced/src/platform/win32/console.rs` 引 §15),
所以本节后移编 §16 并放到文末——**内容一字未改,只改了编号和位置**。
这一节只放「去哪看」的三句话,手册本体在 **`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 向量)。
- **签名就绪度与操作步骤(M255)**:今天 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是**空的** ⇒ 任何正式
二进制都验不过任何清单(fail-closed)。读数一条命令:`python3 packaging/check_signing_ready.py`
(三行 + `0/1/2`,未就绪时打印一行修复命令);CI 里同一条读数在 `signing-status.yml` 的 job summary
上,**那是播报不是门禁**(不应设为 required)。要动手做的那 5 步在
[`docs/signing-runbook.md`](docs/signing-runbook.md)(不含任何真实密钥)。
- **不得混淆**:项目自有签名解决的是「更新器怎么判断这份清单是不是我们发的」,**不解决 SmartScreen**
——用户双击 exe 时看到的「未知发布者」要等 Authenticode 证书,那是另一条线。
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""GPU 截图证据判定(M249):把「这张图真是 GPU 档出来的」变成机器判据,而不是靠人读日志。
为什么需要它:M246 实测到 `--renderer=gpu` 落到 RX 6600 XT / Vulkan(DiscreteGpu)并出图,但那份
结论是**人读 258 行日志**得出的。日志少一行、`Selected` 换了适配器、PNG 是上一次的残留,人眼复核
一遍就得重来。本脚本把那套判据固定成三分钟能复跑一遍的门禁,且**缺哪条就点名哪条**。
入参 = 一份 stderr 日志 + 一张 PNG(可选 `--expect-refuse` 走负例口径)。正例六条判据(每条抓得住
什么、抓不住什么见 `packaging/docs/gpu-evidence.md` §2):
1. **全部** `[shot] … renderer=` 行都是 `wgpu`——不是「出现过」(把 CPU 那次的 stderr `>>` 拼进来
就会骗过旧写法),也**不是** `[probe] renderer = wgpu`:后者只是启动前的意图,M246 的 A6 负例里
它照样打印 wgpu,却一个像素都没画。
2. `Selected: AdapterInfo { … device_type: X }` 的 `X` 不在 `DISALLOWED_DEVICE_TYPES` 内;
**取不到 `X` 也算失败**(多行 Debug 漂移不许静默给绿)。
3. PNG 能解码且像素数 > 0。
4. PNG 宽高 = 日志里 `[shot] W×H` 声明的宽高(把图和日志钉在一起)。
5. `[backend] final=wgpu result=started`(后端自己的结论行与上面几条不矛盾)。
6. PNG 过 `check_screenshot.py` 的非空白判据(复用它的解码器与阈值,不复制第二份)。
`--expect-refuse` 是负例口径(M246 的 A6):要求 `[backend] final=… via=refuse`、**没有**任何
`[shot]` 出图行、且目标 PNG 不存在。它证明「该拒绝时真拒绝了」,不证明「GPU 能出图」。
零第三方依赖;PNG 只解码一次,用的是同目录 `check_screenshot.py` 的 `decode_png` / `measure` / `verdict`。
用法::
python packaging/check_gpu_shot.py shot.stderr.log shot.png # 正例:判定,0/1 退出
python packaging/check_gpu_shot.py a6.stderr.log --expect-refuse # 负例:判定拒绝是否成立
python packaging/check_gpu_shot.py --self-test # 19 条向量(数据+脚手架都在 tests/)
"""
from __future__ import annotations
import re
import sys
import zlib
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) # 同目录 import(-m 方式调用时也要成立)
from check_screenshot import ( # noqa: E402
DEFAULT_MIN_EDGE_RATIO,
DEFAULT_MIN_STDDEV,
DEFAULT_MIN_UNIQUE,
PngError,
decode_png,
measure,
verdict,
)
#: 这些 `device_type` 等于「不是硬件 GPU」:`Cpu` = WARP/软渲染适配器,`Other` = 未分类(本机那条
#: `Other` 是 Gl 兼容层)。用 `not in` 而不是 `in {DiscreteGpu, IntegratedGpu}`,与
#: `crates/app-iced/src/probe.rs:165-167` 的硬件判据同口径:`VirtualGpu` 之类新词条不会因为本脚本
#: 没登记就被当成「不是硬件」。反过来,把 `Cpu` 从这张禁令里拿掉正是最想抓的回归(向量第 3 条)。
DISALLOWED_DEVICE_TYPES = frozenset({"Cpu", "Other"})
#: 截图行里的 renderer。只认 `[shot]` 行 —— `[probe] renderer = …` 是启动前的意图,不是结果。
SHOT_RENDERER_RE = re.compile(r"^\[shot\][^\n]*?\brenderer=(\S+)", re.MULTILINE)
#: `[shot] 1180x760 scale=1 -> …`:出图行声明的像素尺寸。
SHOT_SIZE_RE = re.compile(r"^\[shot\]\s+(\d+)x(\d+)\b", re.MULTILINE)
#: `[gpu] …compositor: Selected: AdapterInfo { … }`:真正建出来的那块适配器。这两处约束不是洁癖:
#: 每份日志里还有一行 `[probe] 取证:出现 …Selected: AdapterInfo…` 的**提示文本**(含同样几个字、
#: 但没有 `{`),锚 `^\[gpu\]` + 要求紧跟 `{` 才不会被那行骗过去。
SELECTED_RE = re.compile(r"^\[gpu\][^\n]*Selected:\s*AdapterInfo\s*\{(?P<body>[^\n]*)", re.MULTILINE)
ADAPTER_NAME_RE = re.compile(r'name:\s*"([^"]*)"')
DEVICE_TYPE_RE = re.compile(r"device_type:\s*(\w+)")
ADAPTER_BACKEND_RE = re.compile(r"backend:\s*(\w+)")
#: `[backend] final=… result=… … via=…`:后端自己的结论行。
BACKEND_FINAL_RE = re.compile(r"^\[backend\] final=(\S+)\s+result=(\S+)(?P<rest>[^\n]*)", re.MULTILINE)
VIA_RE = re.compile(r"\bvia=(\w+)")
#: 判据编号 → 标签。编号是报告里的次序,也在 `--self-test` 的向量里被逐条点名。
LABELS = {
1: "截图行 renderer=wgpu",
2: "Selected: AdapterInfo 的 device_type 不是 Cpu/Other",
3: "PNG 能解码且非空",
4: "PNG 宽高 = 日志 [shot] 的宽高",
5: "后端结论行 final=wgpu result=started",
6: "PNG 不是空白帧",
7: "后端结论行 via=refuse",
8: "没有出图行([shot] 尺寸 + renderer 都不该有)",
9: "目标 PNG 不存在(拒绝出图就该一张都没有)",
}
class Report:
"""逐条判据的结论。失败时由 `print_report` 逐条点名,绝不只印一个 FAILED。"""
def __init__(self) -> None:
self.items: list[tuple[int, bool, str]] = []
def add(self, criterion: int, ok: bool, detail: str = "") -> bool:
self.items.append((criterion, ok, detail))
return ok
def problems(self) -> list[str]:
return [LABELS[criterion] for criterion, ok, _detail in self.items if not ok]
def print_report(self, log_path: Path, png_path: Path | None) -> None:
for criterion, ok, detail in self.items:
print(f"{'[ok]' if ok else '[fail]'} {criterion}. {LABELS[criterion]}" + (f":{detail}" if detail else ""))
target = str(log_path) if png_path is None else f"{log_path} + {png_path}"
problems = self.problems()
if problems:
print(f"[fail] 判据没过 {len(problems)}/{len(self.items)} 条({target})", file=sys.stderr)
else:
print(f"[ok] {len(self.items)} 条判据全过({target})")
def read_log(path: Path) -> str:
"""日志按 UTF-8 读、坏字节替换 —— 判据只看 ASCII 标记,不该因为一行中文乱码就崩。"""
return path.read_text(encoding="utf-8", errors="replace")
def shot_renderers(text: str) -> list[str]:
return SHOT_RENDERER_RE.findall(text)
def shot_size(text: str) -> tuple[int, int] | None:
match = SHOT_SIZE_RE.search(text)
return (int(match.group(1)), int(match.group(2))) if match else None
def _pick(pattern: re.Pattern[str], text: str, default: str | None = "?") -> str | None:
match = pattern.search(text)
return match.group(1) if match else default
def selected_adapter(text: str) -> dict[str, str | None] | None:
"""只取 `Selected:` 那一行:`Available adapters: [...]` 里有 12 条 `AdapterInfo`,取错就全错。
`device_type` 取不到时返回 `None`(**不是** `"?"`):一旦 iced 改成 pretty-print Debug,
字段就落不到 `{` 同一行,此时按「取不到 = 判红」处理,不许静默给绿(M249 r1 P2-1)。
"""
match = SELECTED_RE.search(text)
if match is None:
return None
body = match.group("body")
return {
"name": _pick(ADAPTER_NAME_RE, body),
"device_type": _pick(DEVICE_TYPE_RE, body, None),
"backend": _pick(ADAPTER_BACKEND_RE, body),
}
def backend_final(text: str) -> dict[str, str] | None:
match = BACKEND_FINAL_RE.search(text)
if match is None:
return None
via = VIA_RE.search(match.group("rest"))
return {"final": match.group(1), "result": match.group(2), "via": via.group(1) if via else ""}
def decode_shot(png_path: Path, report: Report) -> tuple[int, int, dict | None]:
"""解码 PNG 并核对像素数,返回 (宽, 高, 统计)。读不出就记一条失败并返回 0 尺寸。"""
try:
width, height, rows, channels = decode_png(png_path)
except (PngError, OSError, zlib.error) as error:
report.add(3, False, f"读不出 {png_path}:{error}")
return 0, 0, None
pixels = width * height
report.add(3, pixels > 0, f"{width}x{height} = {pixels} 像素")
return (width, height, measure(width, height, rows, channels)) if pixels > 0 else (width, height, None)
def judge_evidence(log_path: Path, png_path: Path) -> Report:
"""正例口径:这份日志 + 这张 PNG 能不能当「GPU 档真的出了这一帧」的证据。"""
report = Report()
try:
text = read_log(log_path)
except OSError as error:
report.add(1, False, f"日志读不出 {log_path}:{error}")
return report
renderers = list(dict.fromkeys(shot_renderers(text)))
# 「这张图是 wgpu 出的」= 日志里**每一行** `[shot] … renderer=` 都说 wgpu。用 `in` 就退化成
# 「曾经出现过」:把 CPU 那次的 stderr `>>` 追加进来、配 CPU 那张图,会静默判绿(M249 r1 P2-2)。
report.add(
1,
set(renderers) == {"wgpu"},
"实际 renderer=" + "/".join(renderers) if renderers else "日志里没有 `[shot] … renderer=…` 行(没走到出图那一步?)",
)
adapter = selected_adapter(text)
if adapter is None:
report.add(2, False, "日志里没有 `Selected: AdapterInfo { … }`(wgpu 没建成设备,或只打印了 Available adapters)")
else:
device_type = adapter["device_type"]
# `None` = 字段不在 `{` 同一行(多行 Debug 漂移)或字段缺失:按取不到即失败处理,
# 宁可假红也不许「格式一变就静默给绿」。
report.add(
2,
device_type not in (None, "?") and device_type not in DISALLOWED_DEVICE_TYPES,
f'device_type={device_type}({adapter["name"]} / {adapter["backend"]})'
if device_type not in (None, "?")
else "取不到 device_type:`Selected: AdapterInfo {` 与字段不在同一行(多行 Debug),或字段缺失",
)
width, height, stats = decode_shot(png_path, report)
size = shot_size(text)
if size is None:
report.add(4, False, "日志里没有 `[shot] WxH …` 出图行,尺寸无从对账")
elif width == 0:
report.add(4, False, f"PNG 没解出来,无法核对日志声明的 {size[0]}x{size[1]}")
else:
report.add(4, (width, height) == size, f"日志 {size[0]}x{size[1]} vs PNG {width}x{height}")
final = backend_final(text)
if final is None:
report.add(5, False, "日志里没有 `[backend] final=…` 行(ULL_ACCEPTANCE=1 才有)")
else:
report.add(
5,
final["final"] == "wgpu" and final["result"] == "started",
f"final={final['final']} result={final['result']}" + (f" via={final['via']}" if final["via"] else ""),
)
if stats is None:
report.add(6, False, "上一步没解出像素,无从统计")
else:
blank = verdict(stats, DEFAULT_MIN_UNIQUE, DEFAULT_MIN_STDDEV, DEFAULT_MIN_EDGE_RATIO)
report.add(
6,
not blank,
f"唯一色 {stats['unique_colors']} 亮度σ {stats['luma_stddev']} 强边缘 {stats['edge_ratio']}",
)
return report
def judge_refuse(log_path: Path, png_path: Path | None) -> Report:
"""负例口径:这次启动是不是**确实拒绝**降级出图(而不是悄悄换后端出了一张)。"""
report = Report()
try:
text = read_log(log_path)
except OSError as error:
report.add(7, False, f"日志读不出 {log_path}:{error}")
return report
final = backend_final(text)
if final is None:
report.add(7, False, "日志里没有 `[backend] final=…` 行")
else:
report.add(7, final["via"] == "refuse", f"final={final['final']} result={final['result']} via={final['via'] or '<缺>'}")
renderers = list(dict.fromkeys(shot_renderers(text)))
size = shot_size(text)
surplus = (["renderer=" + "/".join(renderers)] if renderers else []) + (
[f"[shot] {size[0]}x{size[1]}"] if size is not None else []
)
report.add(8, not surplus, "存在出图行:" + "、".join(surplus) if surplus else "")
if png_path is None:
return report
if png_path.exists():
report.add(9, False, f"{png_path} 存在({png_path.stat().st_size} 字节)")
else:
report.add(9, True, f"{png_path} 不存在")
return report
def main(argv: list[str]) -> int:
args = list(argv)
positional: list[str] = []
expect_refuse = False
while args:
arg = args.pop(0)
if arg == "--expect-refuse":
expect_refuse = True
elif arg == "--self-test":
sys.path.insert(0, str(Path(__file__).resolve().parent / "tests"))
from gpu_shot_selftest import run_selftest # 延迟 import:脚手架反过来要拿本模块的判据函数
return run_selftest(judge_evidence, judge_refuse)
elif arg in ("-h", "--help"):
print(__doc__)
return 0
elif arg.startswith("-"):
print(f"::error::不认识的参数:{arg}", file=sys.stderr)
return 2
else:
positional.append(arg)
if not positional:
print("::error::至少要给一份 stderr 日志(--help 看用法)", file=sys.stderr)
return 2
if len(positional) > 2:
print(f"::error::最多两个位置参数(日志 + PNG),实际 {len(positional)} 个", file=sys.stderr)
return 2
log_path = Path(positional[0])
png_path = Path(positional[1]) if len(positional) == 2 else None
if png_path is None and not expect_refuse:
print("::error::正例必须给出那张 PNG(只有负例可用 --expect-refuse 省略)", file=sys.stderr)
return 2
report = judge_refuse(log_path, png_path) if expect_refuse else judge_evidence(log_path, png_path)
report.print_report(log_path, png_path)
return 1 if report.problems() else 0
# ────────────────────────── 自测 ──────────────────────────
#
# 向量数据在 `packaging/tests/gpu_shot_vectors.json`,造样本与逐条比对的脚手架在
# `packaging/tests/gpu_shot_selftest.py`(判据脚本自己守 ≤400 行,脚手架不占这里的行数)。
# 每条的 `expect_fail` 是**期望的失败判据清单**,逐条对:关掉一条判据、或让某条判据被别的
# 东西掩盖,清单就对不上。真图不在仓库里(`DevDocs/` 被 ignore),所以这颗钉子守判据,真实
# 产物由报告里的实跑退出码守。
if __name__ == "__main__":
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(errors="backslashreplace")
sys.exit(main(sys.argv[1:]))
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""PE 子系统守卫(M196):**读产物 PE 头的 `Subsystem` 字段**,不许用「代码看起来对」代替。
起因:群友 2026-11-08 实测发布构建「运行时会带一个控制台窗口在底下」。根因是产物 PE 头的
`Subsystem` 仍是 CUI(3)——`grep -rn windows_subsystem crates/` 当时**零命中**。这个修复在
2026-11-01 做过一次(`37d3039`),但打在已退役的 Slint 树(`crates/app`,M118 起被 workspace
`exclude`、不进发布矩阵),所以从未到达 iced 产物。教训不是「忘了属性」,而是**没有任何一步
去看产物本身**:源码层面怎么写都可能被别的树顶替,只有读那 4 个字节是事实。
两向断言(一条也不许静默):
1. **GUI 产物必须是 `Subsystem == 2`(`WINDOWS_GUI`)**:`unlucky-launcher-iced.exe`。
是 3 就意味着双击会多一个控制台宿主窗口——正是本 mission 要修的那个 bug。
2. **CLI 产物必须是 `Subsystem == 3`(`CONSOLE`)**:`ull.exe`。它靠真控制台做 stdout/stderr,
谁把 `windows_subsystem = "windows"` 抄到它头上,`ull --version | …` 就会静默丢输出。
反方向同样要红:只守一个方向时,把属性贴错 crate 会被漏掉。
读法(零第三方依赖,PE32 与 PE32+ 都吃):`MZ` → `e_lfanew`(偏移 `0x3C`) → `PE\\0\\0` →
COFF 头 20 字节 → 可选头起点 → `Subsystem` 在可选头 `+0x44`(两种 magic 一致)。
读数含**文件字节偏移**,报告里贴的是真实位置,不是「我读过了」。
用法(本地与 CI 同一份代码):
python3 packaging/check_pe_subsystem.py --gui target/release/unlucky-launcher-iced.exe \\
--cli target/release/ull.exe
python3 packaging/check_pe_subsystem.py --self-test # 合成 PE 桩,断言该红的红(8 条:红 6 / 绿 2)
`--self-test` 是这条守门的自测证据:它造合成 PE 头(GUI / CUI / 非 MZ / 截断 / 缺文件),
断言「GUI 读成 3 ⇒ 红」「CLI 读成 2 ⇒ 红」「两边都对 ⇒ 绿」——共 **8 条用例(红 6 / 绿 2)**,
另加一条「Subsystem 真落在报出来的那个偏移上」的偏移自证。守门抓不住回归时它自己先红。
"""
from __future__ import annotations
import struct
import sys
import tempfile
from pathlib import Path
#: PE 签名(`e_lfanew` 处那四个字节)。
PE_SIGNATURE = b"PE\0\0"
#: `IMAGE_OPTIONAL_HEADER.Subsystem` 在可选头里的偏移(PE32 与 PE32+ 同一个位置)。
SUBSYSTEM_OFFSET = 0x44
#: 想要的两种取值。
WINDOWS_GUI = 2
CONSOLE = 3
SUBSYSTEM_NAMES = {
0: "UNKNOWN",
1: "NATIVE",
2: "WINDOWS_GUI",
3: "CONSOLE",
5: "OS2_CUI",
7: "POSIX_CUI",
9: "WINDOWS_CE_GUI",
10: "EFI_APPLICATION",
11: "EFI_BOOT_SERVICE_DRIVER",
12: "EFI_RUNTIME_DRIVER",
14: "XBOX",
16: "WINDOWS_BOOT_APPLICATION",
}
#: 可选头 magic → 人话(只用于打印,判据不看它)。
MAGICS = {0x10B: "PE32", 0x20B: "PE32+", 0x107: "ROM"}
class PeError(Exception):
"""读 PE 头失败(不是 PE / 被截断)—— 同样是红的理由,不许当成「跳过」。"""
class PeInfo:
"""一次读数:值 + 它在文件里的位置(位置进报告,是「真读过」的凭据)。"""
__slots__ = ("path", "subsystem", "file_offset", "e_lfanew", "magic", "machine")
def __init__(self, path: Path, subsystem: int, file_offset: int, e_lfanew: int, magic: int, machine: int):
self.path = path
self.subsystem = subsystem
self.file_offset = file_offset
self.e_lfanew = e_lfanew
self.magic = magic
self.machine = machine
@property
def name(self) -> str:
return self.path.name
def describe(self) -> str:
return (
f"[pe] {self.name}: Subsystem={self.subsystem} "
f"({SUBSYSTEM_NAMES.get(self.subsystem, '?')}) @ 文件偏移 0x{self.file_offset:X}"
f"(e_lfanew=0x{self.e_lfanew:X},{MAGICS.get(self.magic, f'0x{self.magic:X}')},"
f"可选头+0x{SUBSYSTEM_OFFSET:X},machine=0x{self.machine:X})"
)
def read_subsystem(path: Path) -> PeInfo:
"""读 PE 可选头的 `Subsystem`。任何一步对不上都抛 [`PeError`],绝不返回「猜的值」。"""
if not path.is_file():
raise PeError(f"{path} 不存在(构建步没跑?还是产物名改了?)")
with path.open("rb") as handle:
dos = handle.read(0x40)
if len(dos) < 0x40:
raise PeError(f"{path} 只有 {len(dos)} 字节,连 DOS 头都不够(文件被截断?)")
if dos[0:2] != b"MZ":
raise PeError(f"{path} 不是 PE:开头是 {dos[0:2]!r},不是 b'MZ'")
e_lfanew = struct.unpack_from("<I", dos, 0x3C)[0]
handle.seek(e_lfanew)
coff = handle.read(4 + 20)
if len(coff) < 24:
raise PeError(f"{path} 的 e_lfanew=0x{e_lfanew:X} 越界(读不到 PE 签名 + COFF 头)")
if coff[0:4] != b"PE\0\0":
raise PeError(f"{path} 在 e_lfanew=0x{e_lfanew:X} 处没有 PE 签名(期望 {PE_SIGNATURE!r}):{coff[0:4]!r}")
machine = struct.unpack_from("<H", coff, 4)[0]
optional_size = struct.unpack_from("<H", coff, 4 + 16)[0]
optional_start = e_lfanew + 24
if optional_size < SUBSYSTEM_OFFSET + 2:
raise PeError(
f"{path} 的 SizeOfOptionalHeader={optional_size},装不下 Subsystem(+0x{SUBSYSTEM_OFFSET:X})"
)
handle.seek(optional_start)
optional = handle.read(SUBSYSTEM_OFFSET + 2)
if len(optional) < SUBSYSTEM_OFFSET + 2:
raise PeError(f"{path} 的可选头被截断:只读到 {len(optional)} 字节")
magic = struct.unpack_from("<H", optional, 0)[0]
if magic not in (0x10B, 0x20B):
raise PeError(f"{path} 的可选头 magic=0x{magic:X} 既不是 PE32(0x10B) 也不是 PE32+(0x20B)")
subsystem = struct.unpack_from("<H", optional, SUBSYSTEM_OFFSET)[0]
return PeInfo(path, subsystem, optional_start + SUBSYSTEM_OFFSET, e_lfanew, magic, machine)
def check(role: str, path: Path, expected: int, errors: list[str]) -> PeInfo | None:
"""按角色断言一个产物的子系统;不匹配就往 `errors` 里写一条带文件注解的 error。"""
try:
info = read_subsystem(path)
except PeError as exc:
errors.append(f"::error::{role} 读数失败:{exc}")
return None
print(info.describe())
if info.subsystem != expected:
want = SUBSYSTEM_NAMES.get(expected, str(expected))
got = SUBSYSTEM_NAMES.get(info.subsystem, str(info.subsystem))
hint = {
WINDOWS_GUI: "双击会多一个控制台宿主窗口(M196 要修的就是这个)",
CONSOLE: "CLI 的 stdout/stderr 会静默丢失(ull 靠真控制台)",
}.get(expected, "")
errors.append(
f"::error file={path}::{role} 的 PE 子系统应为 {expected}({want}),"
f"实际读到 {info.subsystem}({got})@ 偏移 0x{info.file_offset:X};{hint}"
)
return info
def main(argv: list[str]) -> int:
gui: Path | None = None
cli: Path | None = None
args = list(argv)
while args:
arg = args.pop(0)
if arg == "--gui" and args:
gui = Path(args.pop(0))
elif arg == "--cli" and args:
cli = Path(args.pop(0))
elif arg == "--self-test":
return self_test()
elif arg in ("-h", "--help"):
print(__doc__)
return 0
else:
print(f"::error::不认识的参数(或缺少取值):{arg}", file=sys.stderr)
return 2
if gui is None and cli is None:
print("::error::至少要给 --gui <exe> 或 --cli <exe>", file=sys.stderr)
return 2
errors: list[str] = []
if gui is not None:
check("GUI", gui, WINDOWS_GUI, errors)
if cli is not None:
check("CLI", cli, CONSOLE, errors)
for error in errors:
print(error, file=sys.stderr)
if errors:
return 1
print(
f"OK: PE 子系统符合预期(GUI={WINDOWS_GUI} {SUBSYSTEM_NAMES[WINDOWS_GUI]}"
f" / CLI={CONSOLE} {SUBSYSTEM_NAMES[CONSOLE]})"
)
return 0
# ────────────────────────── 自测:合成 PE 桩 ──────────────────────────
def synth_pe(subsystem: int, *, pe32_plus: bool = True, signature: bytes = b"PE\0\0", mz: bytes = b"MZ") -> bytes:
"""造一个只够本脚本读的 PE 头(不执行、不链接,只让 `Subsystem` 落在正确位置)。"""
e_lfanew = 0x80
buf = bytearray(0x200)
buf[0:2] = mz
struct.pack_into("<I", buf, 0x3C, e_lfanew)
buf[e_lfanew : e_lfanew + 4] = signature
struct.pack_into("<H", buf, e_lfanew + 4, 0x8664)
optional_size = 0xF0 if pe32_plus else 0xE0
struct.pack_into("<H", buf, e_lfanew + 4 + 16, optional_size)
optional_start = e_lfanew + 24
struct.pack_into("<H", buf, optional_start, 0x20B if pe32_plus else 0x10B)
struct.pack_into("<H", buf, optional_start + SUBSYSTEM_OFFSET, subsystem)
return bytes(buf)
def self_test() -> int:
failures = 0
with tempfile.TemporaryDirectory(prefix="ull-pe-subsystem-") as tmp:
root = Path(tmp)
def write(name: str, data: bytes) -> Path:
path = root / name
path.write_bytes(data)
return path
cases: list[tuple[str, Path, int, bool]] = [
(
"基线:GUI 产物 Subsystem=2",
write("gui_ok.exe", synth_pe(WINDOWS_GUI)),
WINDOWS_GUI,
False,
),
(
"回归:GUI 产物退回 CUI(3) —— 就是本次 bug 的形状",
write("gui_cui.exe", synth_pe(CONSOLE)),
WINDOWS_GUI,
True,
),
(
"基线:CLI 产物 Subsystem=3",
write("cli_ok.exe", synth_pe(CONSOLE, pe32_plus=False)),
CONSOLE,
False,
),
(
"回归:CLI 产物被贴成 WINDOWS_GUI(2)",
write("cli_gui.exe", synth_pe(WINDOWS_GUI)),
CONSOLE,
True,
),
(
"回归:不是 PE(开头不是 MZ)",
write("not_pe.exe", synth_pe(WINDOWS_GUI, mz=b"ZZ")),
WINDOWS_GUI,
True,
),
(
"回归:PE 头被截断",
write("truncated.exe", synth_pe(WINDOWS_GUI)[: optional_start_of(synth_pe(WINDOWS_GUI)) + 0x10]),
WINDOWS_GUI,
True,
),
(
"回归:签名不是 PE\\0\\0",
write("bad_sig.exe", synth_pe(WINDOWS_GUI, signature=b"PX\0\0")),
WINDOWS_GUI,
True,
),
(
"回归:文件根本不存在(构建步没跑)",
root / "missing.exe",
WINDOWS_GUI,
True,
),
]
for label, path, expected, want_red in cases:
errors: list[str] = []
check("SELFTEST", path, expected, errors)
got_red = bool(errors)
if got_red != want_red:
want = "红" if want_red else "绿"
print(
f"::error::自测失败:{label} 应该{want},实际 {'红' if got_red else '绿'}",
file=sys.stderr,
)
failures += 1
else:
print(f"OK: {label} → {'红' if got_red else '绿'}")
# 位置读数本身也要对:GUI 桩的 Subsystem 必须正好落在报告的那个文件偏移上。
gui = write("offset.exe", synth_pe(WINDOWS_GUI))
info = read_subsystem(gui)
if gui.read_bytes()[info.file_offset : info.file_offset + 2] != struct.pack("<H", WINDOWS_GUI):
print(f"::error::自测失败:报出来的偏移 0x{info.file_offset:X} 上不是 Subsystem", file=sys.stderr)
failures += 1
else:
print(f"OK: 偏移自证 0x{info.file_offset:X} 处的两字节正是 {WINDOWS_GUI}")
if failures:
return 1
print("OK: PE 子系统守卫自测全部通过(该红的红、该绿的不绿)")
return 0
def optional_start_of(pe: bytes) -> int:
"""合成桩里可选头的起点(给「截断」用例算切点用)。"""
return struct.unpack_from("<I", pe, 0x3C)[0] + 24
if __name__ == "__main__":
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(errors="backslashreplace")
sys.exit(main(sys.argv[1:]))
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Assert the PE **version resource is really inside the artifact** (M190).
Writing `CompanyName` into `build.rs` proves nothing — this reads the produced `.exe` bytes and
looks for the `VS_VERSIONINFO` structure (`FIXEDFILEINFO.dwSignature = 0xFEEF04BD`, key strings in
UTF-16LE) plus the exact values Windows "Properties → Details" must show:
CompanyName Unlucky Launcher Project
ProductName Unlucky Launcher
FileDescription Unlucky Launcher
LegalCopyright © 2026 Unlucky Launcher Project · Apache-2.0
FileVersion <Cargo.toml version, four parts>
It is deliberately a *byte* check, not a "did the build script run" check: a resource that got lost
in linking still fails here. `--expect-version` also compares `dwFileVersionMS/LS` with the crate
version, so "version drifted from Cargo.toml" is caught.
python3 packaging/check_pe_version.py <exe>... --expect-version 0.2.3
python3 packaging/check_pe_version.py --self-test
"""
from __future__ import annotations
import argparse
import struct
import sys
from pathlib import Path
PE_MAGIC = bytes([0x50, 0x45, 0, 0]) # "PE" + two NUL
FIXED_SIGNATURE = b"\xbd\x04\xef\xfe" # FIXEDFILEINFO.dwSignature = 0xFEEF04BD
PE_VERSION_SOURCE = Path(__file__).resolve().parent / "pe_version.rs"
# 承诺的署名(与 packaging/pe_version.rs 同一份口径;`--self-test` 解析那个文件对拍)。
EXPECTED_STRINGS = {
"CompanyName": "Unlucky Launcher Project",
"ProductName": "Unlucky Launcher",
"FileDescription": "Unlucky Launcher",
"LegalCopyright": "© 2026 Unlucky Launcher Project · Apache-2.0",
}
def utf16(text: str) -> bytes:
return text.encode("utf-16-le")
def four_part(version: str) -> tuple[int, int, int, int]:
"""`v0.2.3` / `0.2.3-beta.1` → `(0, 2, 3, 0)`(与 `pe_version.rs::four_part` 同规则)。"""
numeric = version.split("-")[0].split("+")[0].strip().lstrip("v")
parts = [0, 0, 0, 0]
for index, piece in enumerate(numeric.split(".")[:4]):
try:
parts[index] = min(int(piece), 0xFFFF)
except ValueError:
parts[index] = 0
return (parts[0], parts[1], parts[2], parts[3])
def pe_subsystem(data: bytes) -> int | None:
"""可选头里的 `Subsystem`(2 = GUI,3 = 控制台);PE 头不对时返回 None。"""
if len(data) < 0x40 or data[:2] != b"MZ":
return None
offset = struct.unpack_from("<I", data, 0x3C)[0]
if offset + 0x5C > len(data) or data[offset : offset + 4] != PE_MAGIC:
return None
return struct.unpack_from("<H", data, offset + 24 + 0x44)[0]
def check(path: Path, expect_version: str | None) -> list[str]:
"""返回没过的判据(空 = 通过)。"""
failures: list[str] = []
data = path.read_bytes()
if len(data) < 0x100:
return [f"{path}: 文件太小({len(data)} 字节),不像 PE"]
subsystem = pe_subsystem(data)
if subsystem is None:
return [f"{path}: 不是可解析的 PE(MZ / PE 头不对)"]
has_resource = FIXED_SIGNATURE in data
if not has_resource:
failures.append(f"{path}: 找不到 FIXEDFILEINFO 签名 0xFEEF04BD —— 这个 exe 里**没有版本资源**")
for key in ("VS_VERSION_INFO", "StringFileInfo", "VarFileInfo", "Translation"):
if utf16(key) not in data:
failures.append(f"{path}: 版本资源里缺结构键 `{key}`(UTF-16LE 未命中)")
for field, value in EXPECTED_STRINGS.items():
if utf16(field) not in data:
failures.append(f"{path}: 缺字段名 `{field}`")
if utf16(value) not in data:
failures.append(f"{path}: 字段 `{field}` 的值不是 `{value}`")
if utf16(f"{path.stem}.exe") not in data:
failures.append(f"{path}: `OriginalFilename` 不是 `{path.stem}.exe`")
if expect_version:
parts = four_part(expect_version)
dotted = ".".join(str(part) for part in parts)
if utf16(dotted) not in data:
failures.append(f"{path}: 版本字符串 `{dotted}` 不在资源里(与 Cargo.toml 不同源?)")
index = data.find(FIXED_SIGNATURE)
if index >= 0 and index + 16 <= len(data):
ms, ls = struct.unpack_from("<II", data, index + 8)
actual = (ms >> 16, ms & 0xFFFF, ls >> 16, ls & 0xFFFF)
if actual != parts:
failures.append(f"{path}: 数值版本 {actual} 与期望 {parts} 不一致(dwFileVersion)")
else:
failures.append(f"{path}: FIXEDFILEINFO 之后读不到 dwFileVersion")
print(
f"{path.name}: {len(data)} 字节 / subsystem={subsystem}(2=GUI 3=控制台)"
f" / 版本资源={'有' if has_resource else '无'}"
)
return failures
def self_test() -> int:
"""口径表与 `pe_version.rs` 对拍 + 版本换算 + 「假 PE 必须红」。"""
import os
import tempfile
failures = 0
source = PE_VERSION_SOURCE.read_text(encoding="utf-8")
for field, value in (
("COMPANY_NAME", EXPECTED_STRINGS["CompanyName"]),
("PRODUCT_NAME", EXPECTED_STRINGS["ProductName"]),
("COPYRIGHT", EXPECTED_STRINGS["LegalCopyright"]),
):
if f'pub const {field}: &str = "{value}";' not in source:
print(f"FAIL: pe_version.rs 里找不到 {field} = {value!r}", file=sys.stderr)
failures += 1
for case, expect in (("0.2.3", (0, 2, 3, 0)), ("v1.2.3-beta.4", (1, 2, 3, 0)), ("1.2.3.4", (1, 2, 3, 4))):
if four_part(case) != expect:
print(f"FAIL: four_part({case!r}) = {four_part(case)},期望 {expect}", file=sys.stderr)
failures += 1
# 判据本身要能变红:造一个「像 PE 但没有版本资源」的最小文件,check() 必须报错
# (否则这个脚本对任何输入都会「绿」,门禁等于没有)。
fake = bytearray(0x200)
fake[0:2] = b"MZ"
struct.pack_into("<I", fake, 0x3C, 0x40)
fake[0x40:0x44] = PE_MAGIC
struct.pack_into("<H", fake, 0x40 + 24 + 0x44, 2)
with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as handle:
handle.write(bytes(fake))
name = handle.name
problems = check(Path(name), "0.2.3")
os.unlink(name)
if not problems:
print("FAIL: 没有版本资源的假 PE 竟然通过了(判据失效)", file=sys.stderr)
failures += 1
print("self-test:", "ok" if failures == 0 else f"{failures} 处失败")
return 0 if failures == 0 else 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="读产物字节断言 PE 版本资源(M190)")
parser.add_argument("artifacts", nargs="*", type=Path, help="要检查的 exe")
parser.add_argument("--expect-version", help="与 Cargo.toml 同源的版本号(如 0.2.3)")
parser.add_argument("--self-test", action="store_true", help="自检(口径表 + 版本换算 + 假 PE 必红)")
args = parser.parse_args(argv)
if args.self_test:
return self_test()
if not args.artifacts:
parser.print_help()
return 2
failures: list[str] = []
for artifact in args.artifacts:
if not artifact.is_file():
failures.append(f"{artifact}: 文件不存在")
continue
failures.extend(check(artifact, args.expect_version))
for line in failures:
print(f"error: {line}", file=sys.stderr)
if failures:
print(f"FAIL:{len(failures)} 条判据没过(版本资源没进产物就是没进,构建日志不算证据)", file=sys.stderr)
return 1
print(f"ok: {len(args.artifacts)} 个产物的版本资源齐全")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+394
View File
@@ -0,0 +1,394 @@
#!/usr/bin/env python3
"""发布名单守卫(M164):**名单声明与实际产物必须对得上**,两个方向都管。
起因:`PUBLISH_SLOTS` 是唯一决定「哪几个槽位进 Release」的开关,而它此前只有一半是守住的 ——
「名单里有、产物没有」会被 `release-publish.yml` 的齐全性检查拦下,但反方向**静默**:某个槽位(例如
`windows-arm64`)真编出了产物、名单却还没声明它时,那些文件会在裁剪步被 `rm` 掉,日志里只有一行
`drop:`,没有任何注解、也没有任何非零退出码。修复的那个 tag 里「arm64 其实已经能出包了」这件事就
这么被吞掉,没人会注意到(M164 的任务清单点名要这条)。
四条断言:
1. **名单非空且可解析**:`release-publish.yml` 里必须有 `PUBLISH_SLOTS: "..."`。开关消失时不要静默
变成「一个都不发」。
2. **名单里每一项都是矩阵槽位**:`release.yml` 的槽位表(`dict(slot=... )`)才是槽位的唯一定义。
名单里写错一个字(`windows-aarch64`)此前只会表现为「Release 侧什么也不匹配、齐全性检查红在别处」,
根因要翻两层日志;这条在 PR 阶段就把它点出来。
3. **名单里每一项的 `mode` 都必须是 `ci`**:`mode: probe` 的槽位默认构建根本不跑,写进发布名单等于
每一轮都缺一档 —— 一个自己写死要红的组合。
4. **产物 ⇄ 名单两个方向**(`--dist`,只在发布侧有产物时跑):
- 名单声明了但没有 GUI 或 CLI 产物 ⇒ `::error::` + exit 1(少一档不发版)。声明为发布的多半就是 `mode: ci`
的槽位,所以这条同时是「**声明为 ci 的槽位构建红了必须挡住发布**」的判据 —— 红在 build 矩阵里的那一档不会
让发布跳过(`release` job 是 `always()`),挡住它的就是这里;
- 有产物但名单没声明 ⇒ `::warning::` 明说「未发布、会被裁掉」,**不许静默**(M184 起 `linux-arm64` 正好是
这一档:它 `mode: ci`、每轮都编,但运行时未验所以不在名单,产物留着取证);
- `mode: ci` 却本次没有产物(且不在名单)⇒ 也打 `::warning::`:那一档的构建很可能红了,不能只表现为「表里少一行」;
- 无论哪种,最后都打一张全槽位表:发布 / 未发布(无产物)/ 未发布(名单没声明)。
用法(零第三方依赖,本地与 CI 同一份代码):
python3 packaging/check_publish_slots.py # 源码级断言(PR CI)
python3 packaging/check_publish_slots.py --dist dist --slots "$PUBLISH_SLOTS"
python3 packaging/check_publish_slots.py --self-test # 注入回归,断言每条都变红
`--self-test` 是这条守门的自测证据:把两个 workflow 复制进临时目录、造合成 dist,逐项注入真实回归
(漏一个资产 / 名单拼错 / 名单写了 probe 档 / 名单声明了但产物缺 / 名单没声明却有产物 /
`mode: ci` 却没产物 / 声明的 `linux-x64` 构建红了 / 提前把 `linux-arm64` 写进名单),断言守门
该红的红、该只警告的不红。守门抓不住回归时它自己先红。
"""
from __future__ import annotations
import re
import shutil
import sys
import tempfile
from pathlib import Path
PACKAGING = Path(__file__).resolve().parent
ROOT = PACKAGING.parent
MATRIX_YML = Path(".github/workflows/release.yml")
PUBLISH_YML = Path(".github/workflows/release-publish.yml")
#: 槽位表条目(`plan` job 里那份):`dict(slot="windows-x64", ..., mode="ci")`,一条可能跨两行。
SLOT_RE = re.compile(r'dict\(\s*slot="(?P<slot>[^"]+)"(?P<rest>.*?)\)', re.S)
MODE_RE = re.compile(r'mode="(?P<mode>[^"]+)"')
#: 发布白名单那一行(yaml 里是 `PUBLISH_SLOTS: "windows-x64 linux-x64"`)。
PUBLISH_RE = re.compile(r'^\s*PUBLISH_SLOTS:\s*"(?P<value>[^"]*)"', re.M)
#: 每个槽位必须**两样都有**:GUI 与 CLI 各自一个资产(只按「槽位名出现过」判会被其中一个掩盖)。
ASSET_PREFIXES = (("unlucky-launcher", "GUI"), ("ull", "CLI"))
def stem(slot: str) -> str:
"""槽位键落到文件名里是下划线形态(`windows-arm64` → `windows_arm64`)。"""
return slot.replace("-", "_")
# ────────────────────────── 读两个 workflow ──────────────────────────
def matrix_slots(text: str) -> dict[str, str]:
"""`release.yml` 的槽位表 → {槽位: mode}。表在 `plan` job 里,是槽位的唯一定义。"""
slots: dict[str, str] = {}
for match in SLOT_RE.finditer(text):
mode = MODE_RE.search(match.group("rest"))
slots[match.group("slot")] = mode.group("mode") if mode else ""
return slots
def declared_slots(text: str) -> list[str] | None:
"""`release-publish.yml` 的 `PUBLISH_SLOTS` → 槽位列表;整行不见了返回 `None`(区别于「空名单」)。"""
match = PUBLISH_RE.search(text)
return None if match is None else match.group("value").split()
def parse_slots_arg(raw: str) -> list[str]:
"""`--slots "windows-x64 windows-arm64"` 的取值解析(CI 直接透传 `$PUBLISH_SLOTS`)。"""
return raw.replace(",", " ").split()
# ────────────────────────── 1~3. 源码级断言 ──────────────────────────
def check_source(root: Path, expected: list[str] | None = None) -> list[str]:
problems: list[str] = []
matrix_path = root / MATRIX_YML
publish_path = root / PUBLISH_YML
for path in (matrix_path, publish_path):
if not path.is_file():
return [f"找不到 {path.relative_to(root)}(流水线搬家了?这条守门要跟着搬)"]
matrix = matrix_slots(matrix_path.read_text(encoding="utf-8"))
if not matrix:
return [f"{MATRIX_YML} 里读不到任何槽位(`dict(slot=...)` 的写法变了?槽位表是槽位的唯一定义)"]
declared = declared_slots(publish_path.read_text(encoding="utf-8"))
if declared is None:
return [f"{PUBLISH_YML} 里没有 `PUBLISH_SLOTS:` 这一行:发布白名单消失了(别让它静默变成「一个都不发」)"]
if not declared:
problems.append("`PUBLISH_SLOTS` 是空名单:任何一个槽位都不会进 Release 与 SHA256SUMS")
if expected is not None and expected != declared:
# 运行期实际用的名单必须就是文件里那一行(有人在步内覆盖 PUBLISH_SLOTS 时就对不上)。
problems.append(f"运行期名单 {expected} 与 {PUBLISH_YML} 里声明的 {declared} 不一致")
for slot in declared:
if slot not in matrix:
problems.append(
f"名单里的 `{slot}` 不是矩阵槽位(矩阵只有 {' / '.join(matrix)}):"
"写错一个字,裁剪步就一个也匹配不到,而齐全性检查会红在别处、根因埋两层"
)
elif matrix[slot] != "ci":
problems.append(
f"名单里的 `{slot}` 在矩阵里是 mode={matrix[slot]!r}(默认不构建):"
"声明为发布却默认不编,等于每一轮都缺一档"
)
return problems
# ────────────────────────── 4. 产物 ⇄ 名单(发布侧) ──────────────────────────
def assets_for(dist: Path, slot: str) -> dict[str, list[str]]:
"""{资产前缀: 命中的文件名}——按 `make_artifacts.py::asset_stem()` 的命名规则找。"""
out: dict[str, list[str]] = {}
for prefix, _role in ASSET_PREFIXES:
out[prefix] = sorted(p.name for p in dist.glob(f"{prefix}_*_{stem(slot)}*") if p.is_file())
return out
def check_dist(dist: Path, declared: list[str], matrix: dict[str, str]) -> tuple[list[str], list[str], list[str]]:
"""返回 (errors, warnings, 表格行)。errors 非空 ⇒ 调用方 exit 1。"""
errors: list[str] = []
warnings: list[str] = []
rows: list[str] = ["槽位 名单 本次产物 结论"]
rows.append("-" * 78)
wanted = set(declared)
# 顺序:先按矩阵顺序(对外口径稳定),再有名单里多出来的(check_source 已负责报它是错字)。
order = list(matrix) + [s for s in declared if s not in matrix]
for slot in order:
found = assets_for(dist, slot)
present = [f"{prefix}({len(found[prefix])})" for prefix, _ in ASSET_PREFIXES if found[prefix]]
is_declared = slot in wanted
if is_declared:
missing = [role for prefix, role in ASSET_PREFIXES if not found[prefix]]
for role in missing:
suffix = (
"(它是 mode=ci 的槽位:构建红了就必须挡住本次发布,别把缺一档的 Release 发出去)"
if matrix.get(slot) == "ci"
else ""
)
errors.append(f"发布白名单缺产物:{slot} 的 {role}(`{prefix_for(role)}_*_{stem(slot)}*`){suffix}")
verdict = "发布" if not missing else "缺产物 ⇒ 不发版"
wanted.discard(slot)
elif present:
# **反方向**:真编出来了却没声明。裁剪步会把它 rm 掉,此前这里一句注解都没有。
warnings.append(
f"{slot} 有产物({'、'.join(present)})但不在 PUBLISH_SLOTS 里:本次不发布,产物会被裁掉。"
"若这是修复后的首次成功构建,把槽位加进名单即可(齐全性检查与 SHA256SUMS 自动跟着扩)"
)
verdict = "未发布(名单没声明)"
elif matrix.get(slot) == "ci":
# 声明为「默认在 CI 里构建」的槽位本次却没产物:那一档的构建红了(或矩阵被改窄了)。它不在名单里
# ⇒ **不该**让发布变红(arm64 的策略就是「红了不挡发布」),但也不能静默:否则「又红了」只剩翻 job 列表。
warnings.append(
f"{slot} 是 mode=ci 的槽位(tag 推送默认就编)却本次没有产物:那一档的构建红了或被裁了,"
"本次未发布。它不在 PUBLISH_SLOTS 里,故不影响本次发布"
)
verdict = "未发布(ci 槽位本次无产物)"
else:
verdict = "未发布(本次没有产物)"
rows.append(f"{slot:<20} {'声明' if is_declared else '未声明':<8} {'、'.join(present) or '无':<15} {verdict}")
for slot in sorted(wanted):
errors.append(f"发布白名单里的 `{slot}` 既不是矩阵槽位、也没有产物(详见上面的源码级断言)")
return errors, warnings, rows
def prefix_for(role: str) -> str:
return next(prefix for prefix, name in ASSET_PREFIXES if name == role)
# ────────────────────────── 汇总 ──────────────────────────
def run_checks(root: Path, dist: Path | None = None, expected: list[str] | None = None) -> tuple[list[str], list[str], list[str]]:
problems = check_source(root, expected)
if dist is None or problems:
return problems, [], []
matrix = matrix_slots((root / MATRIX_YML).read_text(encoding="utf-8"))
declared = declared_slots((root / PUBLISH_YML).read_text(encoding="utf-8")) or []
errors, warnings, rows = check_dist(dist, declared, matrix)
return problems + errors, warnings, rows
def report(root: Path, dist: Path | None, expected: list[str] | None = None) -> int:
errors, warnings, rows = run_checks(root, dist, expected)
# 全部走 **stdout**:Actions 在 stdout 上同样识别 `::error::` / `::warning::`,而分两个流写会让
# 「表格 → 注解」的顺序在 CI 日志里不可控(stderr 往往整段先刷出来,注解就跑到它解释的那张表前面)。
for row in rows:
print(row)
for item in warnings:
print(f"::warning::{item}")
for item in errors:
print(f"::error::{item}")
if errors:
print(f"发布名单守卫:{len(errors)} 条不通过")
return 1
print(f"OK: 发布名单守卫全绿({len(warnings)} 条「未发布」提示)")
return 0
# ────────────────────────── 自测:注入回归,断言每条都红 ──────────────────────────
def snapshot(dst: Path) -> None:
for rel in (MATRIX_YML, PUBLISH_YML):
target = dst / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / rel, target)
def fake_dist(parent: Path, slots: tuple[str, ...]) -> Path:
"""合成 dist:每槽位一个 GUI + 一个 CLI 资产(名字形状与 `make_artifacts.py` 一致)。"""
dist = parent / "dist"
dist.mkdir(parents=True, exist_ok=True)
version = "0.0.0"
for slot in slots:
platform, arch = slot.split("-", 1)
suffix = ".exe" if platform == "windows" else ".tar.gz"
(dist / f"unlucky-launcher_{version}_{platform}_{arch}{suffix}").write_bytes(b"gui")
(dist / f"ull_{version}_{platform}_{arch}{suffix}").write_bytes(b"cli")
return dist
def set_declared(root: Path, value: str) -> None:
path = root / PUBLISH_YML
text = path.read_text(encoding="utf-8")
replaced, count = PUBLISH_RE.subn(f'PUBLISH_SLOTS: "{value}"', text)
if count != 1:
raise SystemExit(f"::error::自测自身失效:{PUBLISH_YML} 里找到 {count} 处 PUBLISH_SLOTS")
path.write_text(replaced, encoding="utf-8")
def self_test() -> int:
failures = 0
with tempfile.TemporaryDirectory(prefix="ull-publish-slots-") as tmp:
base = Path(tmp) / "base"
snapshot(base)
declared = declared_slots((base / PUBLISH_YML).read_text(encoding="utf-8")) or []
if not declared:
print("::error::自测的基线读不出 PUBLISH_SLOTS", file=sys.stderr)
return 1
cases: list[tuple[str, Path, Path | None, list[str] | None, bool]] = []
# 0. 基线:名单齐全、产物齐全 ⇒ 绿。
cases.append(("基线(名单与产物都齐)", base, fake_dist(Path(tmp) / "c0", tuple(declared)), None, False))
# 1. 名单声明了,但缺 CLI 资产 ⇒ 必须红(这条就是「少一档不发版」)。
dist = fake_dist(Path(tmp) / "c1", tuple(declared))
for stale in dist.glob("ull_*"):
stale.unlink()
cases.append(("名单声明了但缺 CLI 产物", base, dist, None, True))
# 2. 名单声明了,但整个槽位没有产物 ⇒ 必须红。
cases.append(("名单声明了但整个槽位没产物", base, fake_dist(Path(tmp) / "c2", ()), None, True))
# 3. 有产物、名单没声明 ⇒ 只警告不红(反方向的静默必须被打破,但发布本身没错)。
# 名单内的槽位照旧齐全,只是多出一档没人声明的产物。
cases.append(("产物在但名单没声明(arm64 修复后的典型场景)", base, fake_dist(Path(tmp) / "c3", (*declared, "windows-arm64")), None, False))
# 4. 名单拼错一个字 ⇒ 必须红(此前要翻两层日志才能看出根因)。
typo = Path(tmp) / "c4"
snapshot(typo)
set_declared(typo, "windows-aarch64")
cases.append(("名单拼错成 windows-aarch64", typo, fake_dist(Path(tmp) / "c4d", ("windows-aarch64",)), None, True))
# 5. 名单写了 probe 档(默认不构建)⇒ 必须红。**要挑仍是 probe 的槽位**:`linux-x64` 已翻成 `mode: ci`,
# 再拿它当样例,这条用例就从「必须红」变成「本来该绿」——自测跟着矩阵走,否则它自己先变成假证据。
probe = Path(tmp) / "c5"
snapshot(probe)
set_declared(probe, "windows-x64 macos-arm64")
cases.append(("名单写了 mode=probe 的槽位", probe, fake_dist(Path(tmp) / "c5d", ("windows-x64", "macos-arm64")), None, True))
# 6. 运行期名单与文件里那一行不一致 ⇒ 必须红(防步内覆盖)。
cases.append(("运行期名单与文件不一致", base, fake_dist(Path(tmp) / "c6", tuple(declared)), ["windows-arm64"], True))
# 7. 「把 arm64 加回名单」这一行改动本身是干净的:名单+产物都对得上 ⇒ 绿。
flip = Path(tmp) / "c7"
snapshot(flip)
set_declared(flip, " ".join([*declared, "windows-arm64"]))
cases.append(("名单加上 windows-arm64 且产物齐全(下一 tag 的翻牌预演)", flip, fake_dist(Path(tmp) / "c7d", (*declared, "windows-arm64")), None, False))
# 8. (M184 核心)名单里的 `linux-x64` 构建红了(没有它的产物)⇒ **必须挡住发布**:`release` job 是
# `always()`,构建矩阵里红一档不会把发布跳过;挡住它的只能是这条。
dist = fake_dist(Path(tmp) / "c8", tuple(s for s in declared if s != "linux-x64") + ("linux-arm64",))
cases.append(("名单声明的 linux-x64 构建红了(缺产物)", base, dist, None, True))
# 9. (M184 真实场景)`linux-arm64` 构建成功但不在名单 ⇒ 只警告、不挡发布(它是 `mode: ci` 的槽位,每轮都编)。
cases.append(("linux-arm64 构建成功但不在名单(只警告)", base, fake_dist(Path(tmp) / "c9", (*declared, "linux-arm64")), None, False))
# 10. 反过来:有人把 `linux-arm64` 提前写进名单、而它没构建出来 ⇒ 必须红(解锁条件还没满足)。
early = Path(tmp) / "c10"
snapshot(early)
set_declared(early, " ".join([*declared, "linux-arm64"]))
cases.append(("名单提前声明 linux-arm64 但缺产物", early, fake_dist(Path(tmp) / "c10d", tuple(declared)), None, True))
for label, root, dist, expected, want_red in cases:
errors, warnings, _rows = run_checks(root, dist, expected)
got_red = bool(errors)
if got_red != want_red:
want = "红" if want_red else "绿"
detail = ";".join(errors) or "(无 error)"
print(f"::error::自测失败:{label} 应该{want},实际 {'红' if got_red else '绿'} —— {detail}", file=sys.stderr)
failures += 1
else:
print(f"OK: {label} → {'红' if got_red else '绿'}({len(warnings)} 条未发布提示)")
# 第 3 / 9 条还要断言**确实打了警告**:不静默才是这两条规则的重点。
for ghost in ("windows-arm64", "linux-arm64"):
_e, warnings, _r = run_checks(base, fake_dist(Path(tmp) / f"c-warn-{ghost}", (*declared, ghost)), None)
if not any(ghost in w for w in warnings):
print(f"::error::自测失败:产物在但名单没声明时没有针对 {ghost} 的 ::warning::", file=sys.stderr)
failures += 1
else:
print(f"OK: 产物在但名单没声明 → 有 {ghost} 的 ::warning::")
# 第 9 条的另一半:`mode: ci` 的槽位**连产物都没有**(构建红了)时也要有注解,不能只在表里少一行。
ci_slots = [slot for slot, mode in matrix_slots((base / MATRIX_YML).read_text(encoding="utf-8")).items() if mode == "ci"]
missing_ci = [slot for slot in ci_slots if slot not in declared]
if not missing_ci:
print("::error::自测失败:没有「mode=ci 但不在名单」的槽位,这条用例失去意义(矩阵是不是被改窄了?)", file=sys.stderr)
failures += 1
else:
_e, warnings, _r = run_checks(base, fake_dist(Path(tmp) / "c-ci-red", tuple(declared)), None)
for slot in missing_ci:
if not any(slot in w and "ci" in w for w in warnings):
print(f"::error::自测失败:{slot} 是 mode=ci 却没产物时没有 ::warning::(红在哪都不知道)", file=sys.stderr)
failures += 1
else:
print(f"OK: {slot} 是 mode=ci 却无产物 → 有 ::warning::")
if failures:
return 1
print("OK: 发布名单守卫自测全部通过(该红的红、该绿的不红)")
return 0
def main(argv: list[str]) -> int:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(errors="backslashreplace")
root = ROOT
dist: Path | None = None
expected: list[str] | None = None
args = list(argv)
while args:
arg = args.pop(0)
if arg == "--root" and args:
root = Path(args.pop(0)).resolve()
elif arg == "--dist" and args:
dist = Path(args.pop(0)).resolve()
elif arg == "--slots" and args:
expected = parse_slots_arg(args.pop(0))
elif arg == "--self-test":
return self_test()
elif arg in ("-h", "--help"):
print(__doc__)
return 0
else:
print(f"::error::不认识的参数:{arg}", file=sys.stderr)
return 2
if dist is not None and not dist.is_dir():
print(f"::error::--dist {dist} 不是目录(下载 artifact 那一步没跑成?)", file=sys.stderr)
return 1
return report(root, dist, expected)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+391
View File
@@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""发布标记语义守门(M163):**push tag 默认创建稳定版**,这条不是靠注释保证的。
四条断言,任何一条不成立即 exit 1(CI 里红在 `.github/workflows/release-guard.yml`):
1. **标记表**:直接调 `packaging/release_marker.py::marks()`——
push tag(空串)的 create 标记里不许出现 `--prerelease`;`prerelease=true` 两个分支都带;
`prerelease=false` 的 create 稳定、edit 显式 `--prerelease=false`;非法取值必须抛。
2. **bash 往返**:把 `release_marker.py --emit-bash` 的输出真的 `source` 进 bash,读回
`create_mark` / `edit_mark` 两个数组。第 1 条只证明函数对,这一条证明**流水线跑的那段代码**对
(数组拼错、`source` 漏了、`set -u` 炸掉都在这条里现原形)。
3. **流水线必须用这份表**:`release-publish.yml` 的**可执行内容**里不许有 `--prerelease` 字面量
(注释里讲清楚为什么可以),必须调用 `--emit-bash`,且两个数组分别喂给 `gh release create/edit`。
否则表改得再对,流水线也可能自己写一套。
4. **dispatch 默认值 == false**:读 `release.yml` 的 YAML 结构(`on.workflow_dispatch.inputs.prerelease.default`),
不是 grep 字符串——键被搬走 / 改名 / 缺省会当场红。
`--self-mutation` 是这条守门的自测:把四个文件复制到临时目录,逐项注入真实回归(默认值改回
`true`、publish 里塞回 `--prerelease`、标记表的 push 分支改成预发布),断言守门**每一项都红**,
基线**绿**。变异自测本身失败也 exit 1——「守门抓不住」与「代码坏了」是两件事,不能混。
"""
from __future__ import annotations
import importlib.util
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
PACKAGING = Path(__file__).resolve().parent
ROOT = PACKAGING.parent
RELEASE_YML = Path(".github/workflows/release.yml")
PUBLISH_YML = Path(".github/workflows/release-publish.yml")
MARKER_REL = Path("packaging/release_marker.py")
#: release.yml 里「dispatch 的 prerelease 默认值」在 YAML 结构中的位置。
DEFAULT_PATH = ("on", "workflow_dispatch", "inputs", "prerelease", "default")
#: 三种触发方式各自该展开成什么(与 release_marker.py 的表同源,这里独立写一份是为了对拍)。
EXPECTED = {
"": ((), ()),
"true": (("--prerelease",), ("--prerelease",)),
"false": ((), ("--prerelease=false",)),
}
# ────────────────────────── 1. 标记表(语义断言) ──────────────────────────
def load_marker(root: Path):
"""从 `root` 里加载 release_marker.py(变异自测要能加载被改坏的那份)。"""
path = root / MARKER_REL
spec = importlib.util.spec_from_file_location(f"_release_marker_{hash(path)}", path)
if spec is None or spec.loader is None:
raise SystemExit(f"::error::加载不了 {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def check_table(root: Path) -> list[str]:
module = load_marker(root)
problems: list[str] = []
for raw, (create, edit) in EXPECTED.items():
got_create, got_edit = module.marks(raw)
trigger = "push tag(空串)" if raw == "" else f'dispatch prerelease="{raw}"'
if tuple(got_create) != create or tuple(got_edit) != edit:
problems.append(
f"{trigger}:期望 create={create} edit={edit},实际 create={tuple(got_create)} edit={tuple(got_edit)}"
)
# 单独立一条:这条是 v0.2.2 至今 releases/latest 404 的根因,报告里点名它。
push_create, _ = module.marks("")
if "--prerelease" in push_create:
problems.append("push tag 的 create 标记带上了 --prerelease:稳定通道会一直是 404")
for bad in ("yes", "1", "TRUE", "False", "0"):
try:
module.marks(bad)
except ValueError:
continue
problems.append(f"非法取值 {bad!r} 没有被拒绝(会静默变成某个发布标记)")
if not module.CASES:
problems.append("release_marker.CASES 是空的:对外承诺的那张表不见了")
return problems
# ────────────────────────── 2. bash 往返(真跑那段代码) ──────────────────────────
BASH_ROUNDTRIP = r"""
set -uo pipefail
marks=$(mktemp)
"$PYTHON" "$MARKER" --emit-bash --prerelease "$RAW" > "$marks" || { echo "emit failed"; exit 1; }
# shellcheck disable=SC1090
source "$marks"
printf 'create=%s\n' "${create_mark[*]:-}"
printf 'edit=%s\n' "${edit_mark[*]:-}"
rm -f "$marks"
"""
def find_bash() -> str | None:
"""找到「真的 bash」,而不是 Windows 上 System32 里那个 WSL 壳(它不认这套环境)。"""
candidates = [
os.environ.get("BASH"),
shutil.which("bash"),
"C:/Program Files/Git/bin/bash.exe",
"C:/Program Files/Git/usr/bin/bash.exe",
]
for candidate in candidates:
if not candidate:
continue
if "system32" in candidate.replace("/", "\\").lower():
continue
if Path(candidate).is_file() or shutil.which(candidate):
return candidate
return None
BASH = find_bash()
def bash_marks(marker: Path, raw: str) -> tuple[str, str] | None:
if BASH is None:
return None
# 解释器用 sys.executable 而不是写死 python3:CI(ubuntu)上两者等价,本机 Windows 只有
# `python`,写死 python3 会让这条断言在本地跑不起来——一条本地跑不了的守门等于没有。
env = dict(
os.environ,
MARKER=str(marker),
RAW=raw,
PYTHON=sys.executable.replace("\\", "/"),
PYTHONIOENCODING="utf-8",
)
proc = subprocess.run(
[BASH, "-c", BASH_ROUNDTRIP], env=env, capture_output=True, text=True, encoding="utf-8"
)
if proc.returncode != 0:
return None
values = {}
for line in proc.stdout.splitlines():
key, _, value = line.partition("=")
values[key.strip()] = value.strip()
if "create" not in values or "edit" not in values:
return None
return values["create"], values["edit"]
def check_bash_roundtrip(root: Path) -> list[str]:
marker = root / MARKER_REL
problems: list[str] = []
if BASH is None:
return ["找不到 bash:这条断言要真跑流水线里那段 source,不能跳过(CI 是 ubuntu-latest,一定有)"]
for raw, (create, edit) in EXPECTED.items():
got = bash_marks(marker, raw)
want = (" ".join(create), " ".join(edit))
trigger = "push tag(空串)" if raw == "" else f'dispatch prerelease="{raw}"'
if got is None:
problems.append(f"{trigger}:--emit-bash 的产物 source 进 bash 失败")
elif got != want:
problems.append(f"{trigger}:bash 展开成 create={got[0]!r} edit={got[1]!r},期望 create={want[0]!r} edit={want[1]!r}")
return problems
# ────────────────────────── 3. 流水线必须用这份表 ──────────────────────────
def executable_text(text: str) -> str:
"""去掉整行注释后的内容:注释里写 `--prerelease` 是解释,可执行位置上出现才是回归。"""
return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
def check_pipeline(root: Path) -> list[str]:
path = root / PUBLISH_YML
if not path.is_file():
return [f"找不到 {PUBLISH_YML}(发布流水线搬家了?这条守门要跟着搬)"]
text = path.read_text(encoding="utf-8")
live = executable_text(text)
problems: list[str] = []
hits = [
line.strip()
for line in live.splitlines()
# 唯一允许出现它的位置:把值喂给表自己的那一行(`release_marker.py ... --prerelease "$PRERELEASE"`)。
# 其它任何可执行位置(`gh release` 的旗子、自己写的 case 分支)都是回归。
if "--prerelease" in line and "release_marker.py" not in line
]
if hits:
problems.append(
"release-publish.yml 的可执行内容里出现了 --prerelease 字面量(标记必须只由 "
"packaging/release_marker.py 产出):" + " | ".join(hits)
)
if "release_marker.py --emit-bash" not in live:
problems.append("release-publish.yml 没有调用 packaging/release_marker.py --emit-bash")
for array in ('"${create_mark[@]}"', '"${edit_mark[@]}"'):
if array not in live:
problems.append(f"release-publish.yml 没有把 {array} 喂给 gh release(create / edit 各要一个)")
if "github.event.inputs.prerelease" not in live:
problems.append("release-publish.yml 不再从 github.event.inputs.prerelease 取值(三态语义的来源没了)")
return problems
# ────────────────────────── 4. dispatch 默认值 == false ──────────────────────────
def yaml_scalars(text: str) -> dict[tuple[str, ...], tuple[str, int]]:
"""极简 YAML 结构读取:只认「缩进 → key: value」的标量行,返回 {路径: (值, 行号)}。
不引第三方依赖(CI 上只有裸 python3):本守门要读的是「某个键在哪个缩进层级上」,不是
完整 YAML 语义。块标量(`run: |`)的内容整段跳过,免得脚本里的 `a: b` 混进来。
"""
out: dict[tuple[str, ...], tuple[str, int]] = {}
stack: list[tuple[int, str]] = []
block_indent: int | None = None
for lineno, raw in enumerate(text.splitlines(), 1):
if not raw.strip() or raw.lstrip().startswith("#"):
continue
indent = len(raw) - len(raw.lstrip(" "))
if block_indent is not None:
if indent > block_indent:
continue
block_indent = None
stripped = raw.strip()
if stripped == "-" or stripped.startswith("- "):
continue
if ":" not in stripped:
continue
key, _, value = stripped.partition(":")
key = key.strip().strip("'\"")
value = re.sub(r"\s+#.*$", "", value.strip())
while stack and stack[-1][0] >= indent:
stack.pop()
path = tuple(k for _, k in stack) + (key,)
if value in ("", "|", ">", "|-", ">-"):
stack.append((indent, key))
if value:
block_indent = indent
else:
out[path] = (value, lineno)
return out
def check_default(root: Path) -> list[str]:
path = root / RELEASE_YML
if not path.is_file():
return [f"找不到 {RELEASE_YML}"]
scalars = yaml_scalars(path.read_text(encoding="utf-8"))
if DEFAULT_PATH not in scalars:
return [
"release.yml 里读不到 "
+ ".".join(DEFAULT_PATH)
+ ":dispatch 的 prerelease 输入被搬走/改名/删了(默认值判据必须留在原处)"
]
value, lineno = scalars[DEFAULT_PATH]
if value.strip().strip("'\"").lower() != "false":
return [
f"release.yml:{lineno} dispatch 的 prerelease 默认值是 {value!r},必须是 false"
"(默认值一旦回 true,Actions 页面点一次的产物就是预发布)"
]
return []
# ────────────────────────── 汇总 ──────────────────────────
def run_checks(root: Path) -> list[str]:
problems: list[str] = []
for name, fn in (
("标记表(push tag ⇒ 稳定版)", check_table),
("bash 往返(流水线真跑的那段)", check_bash_roundtrip),
("流水线只从表里取标记", check_pipeline),
("dispatch 默认值 == false", check_default),
):
found = fn(root)
problems.extend(f"{name}:{item}" for item in found)
return problems
def report(root: Path) -> int:
problems = run_checks(root)
for item in problems:
print(f"::error::{item}", file=sys.stderr)
if problems:
print(f"发布标记语义守门:{len(problems)} 条不通过", file=sys.stderr)
return 1
marker = load_marker(root)
print("OK: 发布标记语义守门全绿")
print(marker.table())
return 0
# ────────────────────────── 变异自测 ──────────────────────────
def snapshot(dst: Path) -> None:
"""把守门依赖的四个文件复制进 `dst`(只复制这四个,保持临时树最小)。"""
for rel in (RELEASE_YML, PUBLISH_YML, MARKER_REL, Path("packaging") / Path(__file__).name):
target = dst / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ROOT / rel, target)
def mutate_default_true(root: Path) -> str:
path = root / RELEASE_YML
text = path.read_text(encoding="utf-8")
scalars = yaml_scalars(text)
_, lineno = scalars[DEFAULT_PATH]
lines = text.splitlines()
lines[lineno - 1] = lines[lineno - 1].replace("false", "true")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return "release.yml 的 dispatch 默认值改回 true"
def mutate_inline_flag(root: Path) -> str:
path = root / PUBLISH_YML
text = path.read_text(encoding="utf-8")
path.write_text(text + " create_mark+=(--prerelease)\n", encoding="utf-8")
return "release-publish.yml 里塞回 --prerelease 字面量"
def mutate_marker_push(root: Path) -> str:
path = root / MARKER_REL
text = path.read_text(encoding="utf-8")
old = ' if value == "":\n return (), ()\n'
new = ' if value == "":\n return (PRERELEASE,), ()\n'
if old not in text:
raise SystemExit(f"::error::变异自测自身失效:{MARKER_REL} 里找不到 push 分支的原样代码")
path.write_text(text.replace(old, new, 1), encoding="utf-8")
return "标记表的 push tag 分支改成预发布"
MUTATIONS = (mutate_default_true, mutate_inline_flag, mutate_marker_push)
def self_mutation() -> int:
with tempfile.TemporaryDirectory(prefix="ull-release-guard-") as tmp:
baseline = Path(tmp) / "baseline"
snapshot(baseline)
problems = run_checks(baseline)
if problems:
for item in problems:
print(f"::error::变异自测的基线本该是绿的:{item}", file=sys.stderr)
return 1
print("OK: 基线绿")
failures = 0
for index, mutate in enumerate(MUTATIONS):
root = Path(tmp) / f"mutation-{index}"
snapshot(root)
label = mutate(root)
if not run_checks(root):
print(f"::error::变异自测失败:注入「{label}」后守门仍然绿(这条守门抓不住它)", file=sys.stderr)
failures += 1
else:
print(f"OK: 注入「{label}」→ 守门红")
if failures:
return 1
print(f"OK: {len(MUTATIONS)} 项变异全部被抓红,基线绿")
return 0
def main(argv: list[str]) -> int:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(errors="backslashreplace")
root = ROOT
args = list(argv)
if "--root" in args:
index = args.index("--root")
root = Path(args[index + 1]).resolve()
del args[index : index + 2]
if "--self-mutation" in args:
return self_mutation()
unknown = [arg for arg in args if arg not in ("-h", "--help")]
if unknown:
print(f"::error::不认识的参数:{unknown}", file=sys.stderr)
return 2
return report(root)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env python3
"""截图「不是一帧空白」判定:CI 运行时冒烟的门禁脚本(零第三方依赖)。
为什么需要它:`--screenshot` 的 PNG 是进程自己编码写出的,只要流程走到 `take_snapshot` 就会得到
「合法 PNG + 退出码 0」;像素断言是**纵深防御**,守的是「进程活着、写出合法 PNG,但场景几乎没画」这一档。
本机可复现的两种失效都是硬失败(运行步就红,不必等像素断言):①完全没有字体 → fontique panic
(退出码 101,不产生 PNG);②不是软渲染(默认 femtovg)→ `take_snapshot is not supported by this
FemtoVG backend`(退出码 1)。其余「渲染器出图但内容没上去」的情形本机暂无样本,属防御性设计。
三个判据(阈值依据见 `--selftest` 与 packaging/ci-runtime-smoke.md):
- `unique_colors`:唯一色数。全白 / 全黑图 = 1;真实 UI 数百到上千。
- `luma_stddev`:亮度标准差。纯色图 = 0;真实 UI 有面板/文字/渐变的明暗层次。
- `edge_ratio`:**强边缘像素占比**——水平相邻像素对里亮度差 ≥8 的比例。纯色图 = 0;真实 UI 的文字与
边框占千分之几到百分之几。用它而不是「平均亮度差」:空数据目录的主界面 57% 是同一片底色,
平均差会被大片平面稀释(实测只有 0.4),而强边缘占比只数真正的边界,跨主题/跨字体都稳定。
只用 Python 标准库:`zlib` 解压 PNG 的 deflate 流,自己反过滤(filter 0~4)。
不引 Pillow 是因为 CI 不该为了「看一张图」多装一套图像栈(也符合项目的依赖白名单纪律)。
用法::
python packaging/check_screenshot.py shot.png # 判定,退出码 0/1
python packaging/check_screenshot.py shot.png --json # 机器可读(CI 日志用)
python packaging/check_screenshot.py --selftest # 打印阈值依据的两组数值
"""
from __future__ import annotations
import argparse
import json
import math
import struct
import sys
import tempfile
import zlib
from collections import Counter
from pathlib import Path
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
# 判定阈值。两侧数值实测(M62 本机 Docker + xvfb + Windows 历史截图):
# - 真实帧:唯一色 342~2352、亮度σ 19.45~27.46、强边缘占比 0.0076~0.0433(最弱是 Linux 空数据主界面);
# - 人为纯色帧:1 / 0.0 / 0.0。
# 阈值取在两者之间、贴近真实下限的 1/5~1/8:换主题/换字体/换窗口尺寸都不会误报,空白帧一定红。
DEFAULT_MIN_UNIQUE = 64
DEFAULT_MIN_STDDEV = 3.0
DEFAULT_MIN_EDGE_RATIO = 0.001
# 「强边缘」的亮度差门槛:≥8/255 肉眼即视为一条边界;抗锯齿造成的 1~2 级抖动不算。
EDGE_LUMA_DELTA = 8
# PNG 颜色类型 → 每像素通道数(调色板 3 走索引,需要 PLTE,本脚本不解)
CHANNELS_BY_COLOR_TYPE = {0: 1, 2: 3, 4: 2, 6: 4}
class PngError(Exception):
"""PNG 结构不受支持或损坏。"""
def _iter_chunks(data: bytes):
"""按 PNG 规范切块:长度(4) + 类型(4) + 数据 + CRC(4)。"""
offset = len(PNG_SIGNATURE)
while offset + 12 <= len(data):
(length,) = struct.unpack(">I", data[offset : offset + 4])
yield data[offset + 4 : offset + 8], data[offset + 8 : offset + 8 + length]
offset += 12 + length
def _unfilter(kind: int, line: bytearray, prev: bytearray, bpp: int) -> None:
"""就地把一条扫描线反过滤(PNG 规范 §9.2 的五个过滤类型)。"""
if kind == 0:
return
if kind == 1: # Sub:加上左边
for index in range(bpp, len(line)):
line[index] = (line[index] + line[index - bpp]) & 0xFF
elif kind == 2: # Up:加上正上方
for index in range(len(line)):
line[index] = (line[index] + prev[index]) & 0xFF
elif kind == 3: # Average:加上 (左 + 上) // 2
for index in range(len(line)):
left = line[index - bpp] if index >= bpp else 0
line[index] = (line[index] + ((left + prev[index]) >> 1)) & 0xFF
elif kind == 4: # Paeth:加上 Paeth 预测子
for index in range(len(line)):
left = line[index - bpp] if index >= bpp else 0
up = prev[index]
upper_left = prev[index - bpp] if index >= bpp else 0
estimate = left + up - upper_left
dist_left = abs(estimate - left)
dist_up = abs(estimate - up)
dist_corner = abs(estimate - upper_left)
if dist_left <= dist_up and dist_left <= dist_corner:
predictor = left
elif dist_up <= dist_corner:
predictor = up
else:
predictor = upper_left
line[index] = (line[index] + predictor) & 0xFF
else:
raise PngError(f"未知的行过滤类型 {kind}")
def decode_png(path: Path) -> tuple[int, int, list[bytearray], int]:
"""读 8bit 非隔行 PNG,返回 (宽, 高, 每行原始字节, 通道数)。"""
data = path.read_bytes()
if not data.startswith(PNG_SIGNATURE):
raise PngError(f"{path} 不是 PNG(签名不符,可能是 0 字节文件或半截写入)")
width = height = depth = color_type = interlace = None
compressed = bytearray()
for kind, body in _iter_chunks(data):
if kind == b"IHDR":
width, height, depth, color_type, _, _, interlace = struct.unpack(">IIBBBBB", body)
elif kind == b"IDAT":
compressed.extend(body)
elif kind == b"IEND":
break
if width is None or height is None:
raise PngError("缺 IHDR 块")
if interlace:
raise PngError("不支持隔行(Adam7)PNG")
if depth != 8:
raise PngError(f"只支持 8bit PNG,实际 {depth}bit")
channels = CHANNELS_BY_COLOR_TYPE.get(color_type)
if channels is None:
raise PngError(f"不支持的颜色类型 {color_type}(调色板 PNG 请先转成 RGB/RGBA)")
raw = zlib.decompress(bytes(compressed))
stride = width * channels
if len(raw) < (stride + 1) * height:
raise PngError(f"像素数据不足:期望 {(stride + 1) * height} 字节,实际 {len(raw)}")
rows: list[bytearray] = []
previous = bytearray(stride)
offset = 0
for _ in range(height):
kind = raw[offset]
offset += 1
line = bytearray(raw[offset : offset + stride])
offset += stride
_unfilter(kind, line, previous, channels)
rows.append(line)
previous = line
return width, height, rows, channels
def measure(width: int, height: int, rows: list[bytearray], channels: int) -> dict:
"""统计「这帧图里有没有信息」的三项指标。"""
colors: Counter[int] = Counter()
luma_sum = 0
luma_square_sum = 0
strong_edges = 0
pair_count = 0
for row in rows:
previous_luma = None
for x in range(width):
base = x * channels
if channels >= 3:
red, green, blue = row[base], row[base + 1], row[base + 2]
else:
red = green = blue = row[base]
colors[(red << 16) | (green << 8) | blue] += 1
# 整数亮度(ITU-R BT.601 系数),避免浮点误差影响阈值比较
luma = (red * 299 + green * 587 + blue * 114) // 1000
luma_sum += luma
luma_square_sum += luma * luma
if previous_luma is not None:
pair_count += 1
if abs(luma - previous_luma) >= EDGE_LUMA_DELTA:
strong_edges += 1
previous_luma = luma
total = width * height
mean = luma_sum / total if total else 0.0
variance = max(luma_square_sum / total - mean * mean, 0.0) if total else 0.0
top_color, top_count = colors.most_common(1)[0] if colors else (0, 0)
return {
"width": width,
"height": height,
"pixels": total,
"unique_colors": len(colors),
"top_color": f"#{top_color:06x}",
"top_color_share": round(top_count / total, 4) if total else 0.0,
"luma_mean": round(mean, 2),
"luma_stddev": round(math.sqrt(variance), 2),
"edge_ratio": round(strong_edges / pair_count, 5) if pair_count else 0.0,
}
def verdict(
stats: dict, min_unique: int, min_stddev: float, min_edge_ratio: float
) -> list[str]:
"""把指标折成「失败原因清单」;空清单 = 通过。"""
problems = []
if stats["pixels"] <= 0:
problems.append("图像尺寸为 0(宽或高是 0)")
return problems
if stats["unique_colors"] < min_unique:
problems.append(
f"唯一色数 {stats['unique_colors']} < 阈值 {min_unique}(像纯色/空白帧)"
)
if stats["luma_stddev"] < min_stddev:
problems.append(
f"亮度标准差 {stats['luma_stddev']} < 阈值 {min_stddev}(整帧明暗无层次)"
)
if stats["edge_ratio"] < min_edge_ratio:
problems.append(
f"强边缘占比 {stats['edge_ratio']} < 阈值 {min_edge_ratio}(没有边界/文字的迹象)"
)
return problems
def encode_png(width: int, height: int, rgb_row) -> bytes:
"""最小 PNG 编码器(8bit RGB、filter 0、zlib 压缩)——只给 `--selftest` 造样图。"""
raw = bytearray()
for y in range(height):
raw.append(0)
raw.extend(rgb_row(y))
out = bytearray(PNG_SIGNATURE)
for kind, body in (
(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)),
(b"IDAT", zlib.compress(bytes(raw), 6)),
(b"IEND", b""),
):
out.extend(struct.pack(">I", len(body)))
out.extend(kind)
out.extend(body)
out.extend(struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF))
return bytes(out)
def selftest() -> int:
"""造两张对照样图跑一遍,打印阈值依据的两组数值。
- 全白 / 全黑:模拟「窗口起来了但一个像素都没画」的空白帧;
- 一像素黑边框 + 中部一条横线:最弱的「有内容」下限(远弱于真实 UI)。
"""
width, height = 1180, 760
cases = {
"flat-white": lambda y: b"\xff" * (width * 3),
"flat-black": lambda y: b"\x00" * (width * 3),
"border-line": lambda y: b"".join(
b"\x00\x00\x00"
if (y in (0, height - 1) or x in (0, width - 1) or y == height // 2)
else b"\xff\xff\xff"
for x in range(width)
),
}
with tempfile.TemporaryDirectory() as tmp:
for name, describe in cases.items():
path = Path(tmp) / f"{name}.png"
path.write_bytes(encode_png(width, height, describe))
stats = measure(*decode_png(path))
failed = verdict(
stats, DEFAULT_MIN_UNIQUE, DEFAULT_MIN_STDDEV, DEFAULT_MIN_EDGE_RATIO
)
print(
json.dumps(
{
"case": name,
"unique_colors": stats["unique_colors"],
"luma_stddev": stats["luma_stddev"],
"edge_ratio": stats["edge_ratio"],
"verdict": "fail" if failed else "pass",
},
ensure_ascii=False,
)
)
return 0
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description="截图非空白判定(CI 运行时冒烟门禁)")
parser.add_argument("png", nargs="?", type=Path, help="待判定的 PNG 路径")
parser.add_argument("--json", action="store_true", help="以 JSON 输出统计结果")
parser.add_argument("--min-unique", type=int, default=DEFAULT_MIN_UNIQUE, help="唯一色数下限")
parser.add_argument("--min-stddev", type=float, default=DEFAULT_MIN_STDDEV, help="亮度标准差下限")
parser.add_argument(
"--min-edge-ratio", type=float, default=DEFAULT_MIN_EDGE_RATIO, help="强边缘占比下限"
)
parser.add_argument("--selftest", action="store_true", help="打印阈值依据的对照数值后退出")
args = parser.parse_args(argv)
if args.selftest:
return selftest()
if args.png is None:
parser.error("需要给出 PNG 路径(或 --selftest)")
try:
stats = measure(*decode_png(args.png))
except (PngError, OSError, zlib.error) as error:
print(f"[fail] 读不出截图 {args.png}: {error}", file=sys.stderr)
return 1
problems = verdict(stats, args.min_unique, args.min_stddev, args.min_edge_ratio)
if args.json:
print(json.dumps({**stats, "problems": problems}, ensure_ascii=False))
else:
print(
f"{args.png}: {stats['width']}x{stats['height']} "
f"唯一色 {stats['unique_colors']} 亮度σ {stats['luma_stddev']} "
f"强边缘 {stats['edge_ratio']} 主色 {stats['top_color']}"
f"({stats['top_color_share'] * 100:.1f}%)"
)
if problems:
for problem in problems:
print(f"[fail] {problem}", file=sys.stderr)
return 1
print("[ok] 截图非空白")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""签名就绪度播报(M255):**只读**、零第三方依赖,三行说清「今天能不能签发一份客户端会认的清单」。
为什么要它:M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在
`PINNED-KEYS` 标记之间是**空的**,而空表一律 `KeyringEmpty` 拒装 —— 于是**今天任何正式二进制都验不过
任何清单**(fail-closed 是成立的,但在用户机器上表现为「更新永远失败」)。工具链早就齐了
(`sign_manifest.py` 的 `--generate-key` / `--check-pinned-keyring` / `--sign` / `--verify-pinned`),
缺的是**操作步骤与可见性**。本脚本只负责「现在差什么」,它不生成钥、不写任何文件、不改 `trust.rs`。
三行就绪度(各一条判据,互不替代):
| 行 | 判据 | 未就绪意味着 |
|---|---|---|
| ① | `PRODUCTION` 表非空:解析 `PINNED-KEYS` 标记之间的 `PinnedKey` 条目,0 条即未就绪 | 客户端一把公钥都没有,任何清单都验不过(今天就是这个状态) |
| ② | 环境里有签名钥:`ULL_MANIFEST_SIGNING_KEY`(或 `--key <文件>`)能解出 32 字节种子,**且**它的公钥在 ① 的表里 | 没钥签不了;钥与信任表不是同一把 ⇒ 发布时 `--check-pinned-keyring` 当场红 |
| ③ | 最近一次发布的 `manifest.json` / `.sig` 痕迹 | **不参与就绪判定**。取不到证据就写「未知」——「本地没留档」不等于「上次没签」 |
②里的对账(`公钥 ∈ 表`)是刻意加的:只说「有钥」会给出一个**看着就绪、发布却红**的假象,与本仓
「别再造假绿的尺子」的血泪账直接冲突。公钥是公开物,回显它是安全的;**种子(`seed_hex`)在本脚本里
一次都不打印**。
②还有第三种读数:**未判定**。CI 的播报 workflow 刻意只在 `main` ref 上注入 Secret(生产签名种子等于
「给全量已装客户端签可信清单」的能力,既有设计把它锁在 tag ref 后面),非 main ref 的运行里这一行会写
「未判定 —— 非 main ref(按设计不注入)」,**不写「没配」**:把「策略不注入」说成「没配」就是一次假读数。
退出码是对外接口(改动即行为变化,`packaging/tests/signing_ready_selftest.py` 钉着它):
`0` 就绪 / `1` 未就绪(含 ② 未判定) / `2` 用法错(trust.rs 或 `--key` 读不到、标记丢了 ——
那时脚本形不成判断,不是「未就绪」)。
**它不是门禁**:CI 里的 `.github/workflows/signing-status.yml` 只把这三行搬进 job summary,
不因「未就绪」变红。理由见 `packaging/docs/signing-runbook.md` §6。
python3 packaging/check_signing_ready.py
python3 packaging/check_signing_ready.py --trust <副本 trust.rs> --key <钥文件> --dist dist
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SIGN = ROOT / "packaging/sign_manifest.py"
RUNBOOK = "packaging/docs/signing-runbook.md"
#: 未就绪时的固定措辞:判据与「人类一眼看懂后果」绑在一起(用例按字面断言这一句)。
FAIL_CLOSED = "这会让已安装用户永远验不过(fail-closed)"
#: 「这一趟按设计没有钥」的原因(人话)。CI 的 `signing-status.yml` 在非 main ref 上把它设成一句话,
#: 于是 ② 读成「未判定」而不是「没配」—— 把**策略不注入**说成**没配置**是一次假读数(M255 r1 P2-1)。
WITHHELD_ENV = "ULL_SIGNING_KEY_WITHHELD"
sys.path.insert(0, str(ROOT / "packaging"))
# 标记 / 环境变量名 / 信任表路径一律**从 sign_manifest.py 取**,不在这里写第二份字面量:
# 两份标记各自漂开时,`--check-pinned-keyring` 与就绪度会给出互相矛盾的读数。
from sign_manifest import KEY_ENV, KID_RE, PIN_BEGIN, PIN_END, TRUST_RS, load_key # noqa: E402
from ed25519_ref import public_key # noqa: E402
Line = tuple[bool, str] # (就绪?, 该行文本)
def table_state(path: Path) -> tuple[bool, str, list[tuple[str, str]]]:
"""① 判据:`PINNED-KEYS` 标记之间有至少一条 `PinnedKey`。
标记缺失 / 文件读不到 ⇒ `RuntimeError`(调用方翻成退出码 2:脚本无法形成判断)。
"""
text = path.read_text(encoding="utf-8")
begin, end = text.find(PIN_BEGIN), text.find(PIN_END)
if begin < 0 or end < begin:
raise RuntimeError(
f"{path} 里找不到 {PIN_BEGIN} / {PIN_END} 标记(标记本身不许动 —— sign_manifest.py 也按它解析)"
)
inside = text[begin:end]
keys = KID_RE.findall(inside)
line_no = text[:begin].count("\n") + 1
if keys:
names = "、".join(f"{kid}({pk[:16]}…)" for kid, pk in keys)
return True, f"就绪 —— 标记之间 {len(keys)} 条:{names}({path}:{line_no} 起)", keys
# 空表就是今天的状态。顺手查一个真实易错点:有人把条目贴到标记**外面**(`];` 之后)。
# ⚠ 本仓另有一把 `TEST_ONLY` 测试钥**本来就在标记之外**(那是它的正确位置),所以这里只**点名**,
# 不替人下结论 —— 上一版写成「贴错位置,等于没钉」,在真实 trust.rs 上是误报(实测抓出来的)。
strays = [kid for kid, _ in KID_RE.findall(text[:begin]) + KID_RE.findall(text[end:])]
hint = ""
if strays:
hint = (
f";文件里另有 {len(strays)} 条在标记之外(key_id:{'、'.join(strays[:3])})"
"—— `TEST_ONLY` 那条本来就在外面,属正常;**生产钥**贴在外面等于没钉"
)
return False, f"未就绪 —— 标记之间 0 条 PinnedKey{hint}({path}:{line_no} 起)", []
def read_key_text(key_file: str | None) -> str:
"""② 的输入文本:`--key <文件>` 或环境变量 `ULL_MANIFEST_SIGNING_KEY`。
读不了(不存在 / 是目录 / 无权限 / 非 UTF-8)⇒ `OSError` / `UnicodeDecodeError`,由 `main` 翻成
**退出码 2**(用法错)。⚠ 这里必须抛而不是吞:吞掉就会变成 rc=1,与「未就绪」同码,
而事实是「你给的路径就是错的」——两者混了,退出码就不再可信(M255 r1 P2-2)。
"""
if key_file:
return Path(key_file).read_text(encoding="utf-8")
return os.environ.get(KEY_ENV, "")
def key_state(raw: str, source: str, pinned: list[tuple[str, str]]) -> tuple[bool, str, str | None]:
"""② 判据:环境里有钥、解析得出、且它的公钥在 ① 的表里。
返回 `(就绪?, 该行文本, 可直接贴进 trust.rs 的那一行 PinnedKey)`。
**任何分支都不打印种子**;回显的只有 `key_id` 与公钥十六进制。
"""
if not raw.strip():
reason = os.environ.get(WITHHELD_ENV, "").strip()
if reason:
# 「策略上不注入」≠「没配置」。这一行必须能分辨,否则非 main ref 的运行会报假的「没配」。
return False, f"未判定(未注入)—— 这一趟的钥按设计没有注入:{reason};这不是「没配」", None
return False, f"未就绪 —— {source} 未设置/为空(没有私钥就签不出任何清单)", None
try:
key_id, seed = load_key(raw)
except SystemExit as error: # load_key 用 SystemExit 报错(它会 sys.exit);这里降级成一行读数
return False, f"未就绪 —— {source} 读不出钥:{error}", None
pk_hex = public_key(seed).hex()
paste = f'PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},'
for pinned_id, pinned_hex in pinned:
if pinned_hex.lower() == pk_hex:
note = "" if pinned_id == key_id else f"(表里叫 {pinned_id},钥文件叫 {key_id};判据是公钥字节,能过)"
return True, f"就绪 —— {source} 里有钥 {key_id},公钥 {pk_hex} 已在信任表里{note}", paste
if pinned:
return False, f"未就绪 —— {source} 里有钥 {key_id}(公钥 {pk_hex}),但**不在**信任表里,发布时会被拦", paste
return False, f"未就绪 —— {source} 里有钥 {key_id}(公钥 {pk_hex}),但信任表是空的,没处钉", paste
def release_state(dist: Path | None, assets: Path | None) -> tuple[bool | None, str]:
"""③ 判据:最近一次发布有没有 `manifest.json` / `.sig` 痕迹。**只取证,不参与就绪判定。**
`None` = 未知(既没给 `--dist` 里的留档,也没给 `--release-assets`)。「本地没留档」不是
「上次没签」的证据,所以这一档只写「未知」,不写「无」。
"""
if dist is not None and dist.is_dir():
manifest = dist / "manifest.json"
signature = dist / f"{manifest.name}.sig"
if manifest.is_file() and signature.is_file():
return True, f"有 —— 本地留档 {manifest} + {signature}"
if manifest.is_file():
return False, f"无 —— 本地 {dist} 里有 manifest.json 但**没有** .sig(那份清单客户端会拒收)"
if assets is not None and assets.is_file():
text = assets.read_text(encoding="utf-8", errors="replace")
# 先剥掉 `.sig` 再判清单:`manifest.json.sig` 里含 `manifest.json`,只做子串匹配时
# 「资产里只有签名」会被读成「清单和签名都在」(r1 复审实测抓到的假读数)。
has_sig = "manifest.json.sig" in text
has_manifest = "manifest.json" in text.replace("manifest.json.sig", "")
if has_manifest and has_sig:
return True, f"有 —— {assets} 的资产清单里同时有 manifest.json 与 manifest.json.sig"
if has_manifest:
return False, f"无 —— {assets} 的资产清单里有 manifest.json,但没有 manifest.json.sig(没签名)"
return False, f"无 —— {assets} 的资产清单里既没有 manifest.json 也没有 .sig(那次发布没走上签名这条链)"
looked = [str(p) for p in (dist, assets) if p is not None]
where = f"(看过:{'、'.join(looked)})" if looked else "(没给 --dist / --release-assets,也没本地 dist/ 留档)"
return None, f"未知 —— 取不到任何发布产物证据{where}"
def fix_hint(table_ok: bool, key_ok: bool, paste: str | None) -> str:
"""未就绪时的**一行**修复命令(不带任何密钥值;钥的事只指向 Secret 名与手册)。
指针必须落在手册**真实**的章节上:`packaging/tests/signing_ready_wiring_selftest.py` 会拿这里的
`§N` 去对 `signing-runbook.md` 的标题集合,漂了当场红(M255 r1 P2-3:`signing-ready.yml` 那个
文件名根本不存在,而指针正是本文件的交付物)。
"""
if not table_ok and paste:
return f"修复:把这一行贴进 {TRUST_RS.name} 的 PINNED-KEYS 标记之间 —— {paste}(手册 {RUNBOOK} §2 步③)"
if not table_ok:
return (
"修复:python3 packaging/sign_manifest.py --generate-key --key-id <key-id> "
f"→ 私钥贴进 Secret {KEY_ENV},打印的 PinnedKey 那行贴进 trust.rs 标记之间(手册 {RUNBOOK} §2 步①–③)"
)
if not key_ok and paste:
return f"修复:把这一行贴进 {TRUST_RS.name} 的 PINNED-KEYS 标记之间 —— {paste}(手册 {RUNBOOK} §2 步③)"
return f"修复:把密钥贴进仓库 Secret {KEY_ENV}(内容 = --generate-key 打印的那行 JSON;手册 {RUNBOOK} §2 步②)"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="签名就绪度播报:三行读数 + 0/1/2 退出码(M255)")
parser.add_argument("--trust", type=Path, default=TRUST_RS, help=f"trust.rs 路径(默认 {TRUST_RS})")
parser.add_argument("--key", metavar="FILE", help=f"钥文件(不给则读环境变量 {KEY_ENV})")
parser.add_argument("--dist", type=Path, default=Path("dist"), help="本地留档目录(默认 dist/;不存在则不看)")
parser.add_argument("--release-assets", type=Path, metavar="FILE", help="某次 Release 的资产清单文本(如 gh release view 的输出)")
parser.add_argument("--line", action="store_true", help="CI 用:额外把三行写进 $GITHUB_STEP_SUMMARY")
args = parser.parse_args(argv)
try:
table_ok, table_line, pinned = table_state(args.trust)
except (OSError, UnicodeDecodeError, RuntimeError) as error:
print(f"error: 读不了信任表:{error}(脚本无法形成判断,按用法错退出)", file=sys.stderr)
return 2
try:
key_text = read_key_text(args.key)
except (OSError, UnicodeDecodeError) as error:
# 与 `--trust` 同款:输入读不到 = 用法错(2)。**不能**退化成 1 —— 1 是「签名未就绪」的读数,
# 混了就会把「我路径写错了」读成「签名没配好」(M255 r1 P2-2)。
print(f"error: 读不了钥文件 {args.key}:{error}(这是用法错,不是「未就绪」)", file=sys.stderr)
return 2
source = f"--key {args.key}" if args.key else f"环境变量 {KEY_ENV}"
key_ok, key_line, paste = key_state(key_text, source, pinned)
release_ok, release_line = release_state(args.dist, args.release_assets)
withheld = not key_ok and not key_text.strip() and bool(os.environ.get(WITHHELD_ENV, "").strip())
lines: list[Line] = [
("① 信任表 PRODUCTION", table_ok, table_line),
("② 环境签名钥", key_ok, key_line),
("③ 最近一次发布痕迹", release_ok is not False, release_line),
]
for label, _ok, text in lines:
print(f"{label}:{text}")
ready = table_ok and key_ok
if ready:
print(f"结论:就绪({args.trust} 里钉了钥,环境里的钥与它同源)—— 可以按 {RUNBOOK} §2 步⑤ 发一次带 .sig 的 Release,再按 §5 回验")
elif withheld:
# 「未判定」不许伪装成「未就绪」:这一趟根本没拿到钥,不是仓库缺钥。
tail = f";① 仍是空的 ⇒ {FAIL_CLOSED}" if not table_ok else ""
print(f"结论:未判定(② 这一趟没注入钥,见上面那行的原因){tail}")
else:
blockers = [label for label, ok, _ in lines[:2] if not ok]
print(f"结论:未就绪(差 {'、'.join(blockers)})—— {FAIL_CLOSED}")
print(fix_hint(table_ok, key_ok, paste))
if args.line:
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
verdict = "就绪" if ready else ("未判定" if withheld else "未就绪")
body = ["## 签名就绪度(M255,播报不是门禁)", ""]
body += [f"- **{label}**:{text}" for label, _ok, text in lines]
body += [
"",
f"- 结论:**{verdict}**",
f"- 本 job 的绿色**不代表签名就绪**:判据只看上面三行字,退出码恒为 0(见 `{RUNBOOK}` §6)。",
]
Path(summary).write_text("\n".join(body) + "\n", encoding="utf-8")
return 0 if ready else 1
if __name__ == "__main__":
raise SystemExit(main())
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""守 `update-test-key`:这把**公开**的测试公钥不许进发布构建(M190 r3)。
背景:`crates/core` 的信任表(`trust::PRODUCTION`)正式构建里是空的(fail-closed,等用户录钥),
测试公钥 `trust::TEST_ONLY` 只在三档「仅测试」构建里并入:`cfg(test)`、测试通道那条 feature、
`update-test-key`。第三档是给 `crates/presenter` 的 `[dev-dependencies]` 用的——presenter 的更新链路
用例要构造「本构建认得」的签名清单,而它编译 core 时既没有 `cfg(test)`,也不该打开
「测试通道」那条 feature(它会**同时**给出「本地目录当更新源」这条路,是另一个更大的口子;
那条由 `check_test_source_off.py` 守。⚠ 本文件**刻意不写那条 feature 的全名**:那个守门按字面
扫 `packaging/**`,写了这里就红——这是它的设计(宁可误杀也不放过),不是笔误)。
这一档比「测试通道」那条 feature 窄,但它仍然是**信任锚的开关**:一旦进发布构建,任何人都能用公开种子
签出「官方更新」。所以它的出现位置必须是**白名单**,而不是「没人会这么写」:
| 允许出现的地方 | 形态 |
|---|---|
| `crates/core/Cargo.toml` | 只能是它自己的声明,且必须是**空数组** `update-test-key = []`(写成带子 feature 的非空数组即红) |
| `crates/presenter/Cargo.toml` | 只能出现在 `[dev-dependencies]` 段内 |
| 本脚本 | 它必须写出这个 feature 名 |
其余任何地方(workflow、`packaging/` 的脚本、根 `Cargo.toml`、`.cargo/config*.toml`、presenter 的
`[dependencies]`)提到它即红。`--self-test` 把这四类真实回归各注入一次(workflow / presenter 的
`[dependencies]` / 根 `Cargo.toml` / core 声明里偷偷带子 feature),并断言合法写法不误杀。
python3 packaging/check_test_key_off.py
python3 packaging/check_test_key_off.py --self-test
"""
from __future__ import annotations
import argparse
import re
import sys
import tempfile
from pathlib import Path
FEATURE = "update-test-key"
ROOT = Path(__file__).resolve().parent.parent
GUARD = Path(__file__).resolve()
#: 扫描面(逐项列举,不用 `**` 全仓:那会把 `target/` 拖进来)。
GLOBS = (
".github/workflows/**/*.yml",
".github/workflows/**/*.yaml",
".github/actions/**/*.yml",
".github/actions/**/*.yaml",
"packaging/**/*.py",
"packaging/**/*.sh",
"packaging/**/*.ps1",
"Cargo.toml",
"crates/*/Cargo.toml",
"Makefile",
"justfile",
"Justfile",
"Taskfile.yml",
"Taskfile.yaml",
"crates/*/Makefile",
"packaging/Makefile",
".cargo/config.toml",
".cargo/config",
"crates/*/.cargo/config.toml",
"crates/*/.cargo/config",
)
#: 白名单:(相对路径, 允许的形态)。形态见 `check_manifest` 的三种判法。
ALLOWED_DECLARATION = "crates/core/Cargo.toml"
ALLOWED_DEV_DEPENDENCY = "crates/presenter/Cargo.toml"
#: 另一条(更宽的)测试通道 feature 的名字。**刻意拼出来**:`check_test_source_off.py` 按字面扫
#: `packaging/**`,这里写全名会让那个守门红 —— 那是它的设计(宁可误杀也不放过),不是笔误。
#: 本文件与它同源同一套手法,别“顺手”把它写成字面量。
OTHER_FEATURE = "-".join(("update", "test", "source"))
#: core 只许**自己的声明**,而且必须是**空数组** `update-test-key = []`。
#: 这里认**内容**、不只认形状:写成带子 feature 的非空数组(`["<另一条 feature>"]`)会让这个「窄口子」
#: 顺手把「宽口子」(环境变量 + `DirSource` 那条测试通道)拉起来 —— 那正是本守门要防的逃逸。
DECLARATION = re.compile(rf"^\s*{re.escape(FEATURE)}\s*=\s*\[\s*\]\s*$")
SECTION = re.compile(r"^\s*\[([^\]]+)\]\s*$")
def strip_comment(line: str) -> str:
"""引号感知地剥注释(与 `check_test_source_off.py` 同一手法,省得两处对同一行有不同看法)。"""
quote = None
for index, char in enumerate(line):
if quote is not None:
if char == quote:
quote = None
elif char in "\"'":
quote = char
elif char == "#" and (index == 0 or line[index - 1].isspace()):
return line[:index]
return line
def check_manifest(path: Path, where: str) -> list[str]:
"""`Cargo.toml`:核心只许自己的声明;presenter 只许 `[dev-dependencies]` 段内。"""
problems: list[str] = []
section = ""
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
code = strip_comment(line).strip()
if not code:
continue
header = SECTION.match(code)
if header:
section = header.group(1).strip()
continue
if FEATURE not in code:
continue
if where == ALLOWED_DECLARATION and DECLARATION.match(code):
continue
if where == ALLOWED_DEV_DEPENDENCY and section == "dev-dependencies":
continue
problems.append(
f"::error file={where}:{number}::{where} 在 `[{section or '顶部'}]` 里写了 `{FEATURE}`;"
"它只许出现在 crates/core/Cargo.toml 自己的声明与 crates/presenter/Cargo.toml 的 "
"`[dev-dependencies]` 里(这把公钥进发布构建 = 谁都能签出「官方更新」)"
)
return problems
def check_plain(path: Path, where: str) -> list[str]:
"""其余文件类型:出现即红(不解释形态)。"""
problems: list[str] = []
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
if FEATURE in strip_comment(line):
problems.append(
f"::error file={where}:{number}::{where} 提到 `{FEATURE}`:它不进 workflow / 打包脚本 / "
"cargo 配置(测试钥只许由 crates/presenter 的 dev-dependencies 打开)"
)
return problems
def scan(root: Path) -> list[str]:
"""扫整个仓库,返回所有问题(空 = 绿)。"""
problems: list[str] = []
for pattern in GLOBS:
for path in sorted(root.glob(pattern)):
if not path.is_file() or path.resolve() == GUARD:
continue
where = path.relative_to(root).as_posix()
try:
if path.name == "Cargo.toml":
problems += check_manifest(path, where)
else:
problems += check_plain(path, where)
except (OSError, UnicodeDecodeError) as error:
problems.append(f"::error file={where}::读不到:{error}")
return problems
# ────────────────────────── 变异自测 ──────────────────────────
BASELINE = {
"crates/core/Cargo.toml": (
"[features]\nother-feature = []\n\n# 注释里提到 update-test-key 不算违规\ntest-key = []\n"
"update-test-key = []\n"
),
"crates/presenter/Cargo.toml": (
"[dependencies]\nunlucky-core = { path = \"../core\" }\n\n"
"[dev-dependencies]\nunlucky-core = { path = \"../core\", features = [\"update-test-key\"] }\n"
),
".github/workflows/ci.yml": "jobs:\n a:\n steps:\n - run: cargo test\n",
}
VECTORS = [
(
"workflow 里打开它",
{".github/workflows/ci.yml": "jobs:\n a:\n steps:\n - run: cargo build --features update-test-key\n"},
"red",
),
(
"presenter 的 [dependencies] 里打开它",
{
"crates/presenter/Cargo.toml": (
"[dependencies]\nunlucky-core = { path = \"../core\", features = [\"update-test-key\"] }\n"
)
},
"red",
),
(
"根 Cargo.toml 把依赖提起来",
{
"Cargo.toml": (
'[workspace.dependencies]\nunlucky-core = { path = "crates/core", '
'features = ["update-test-key"] }\n'
)
},
"red",
),
("打包脚本提到它", {"packaging/x.sh": "cargo build --features update-test-key\n"}, "red"),
(
"core 自己的声明(合法)",
{"crates/core/Cargo.toml": "[features]\nupdate-test-key = []\n"},
"green",
),
(
"core 的声明里偷偷带子 feature(形状合法、内容非法)",
{"crates/core/Cargo.toml": f'[features]\n{FEATURE} = ["{OTHER_FEATURE}"]\n'},
"red",
),
("注释里提到它(合法)", {"packaging/y.py": "# 见 update-test-key 的说明\n"}, "green"),
]
def _scan_case(files: dict[str, str]) -> list[str]:
with tempfile.TemporaryDirectory(prefix="ull-test-key-guard-") as temp:
root = Path(temp)
for relative, text in files.items():
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return scan(root)
def self_test() -> int:
failures = 0
baseline = _scan_case(BASELINE)
if baseline:
print(f"FAIL 基线被判红:{baseline}", file=sys.stderr)
failures += 1
for label, files, expect in VECTORS:
merged = dict(BASELINE)
merged.update(files)
problems = _scan_case(merged)
red = bool(problems)
if (expect == "red") != red:
print(f"FAIL 向量「{label}」期望 {expect},实际 {'red' if red else 'green'}", file=sys.stderr)
failures += 1
print(f"self-test: {'ok' if failures == 0 else f'{failures} 处失败'}({len(VECTORS)} 条向量 + 基线)")
return 0 if failures == 0 else 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="测试公钥不许进发布构建(M190)")
parser.add_argument("--self-test", action="store_true", help="注入变异:该红的红、该绿的绿")
parser.add_argument("--dir", type=Path, default=ROOT, help="仓库根(默认本文件上一级)")
args = parser.parse_args(argv)
if args.self_test:
return self_test()
problems = scan(args.dir)
for line in problems:
print(line, file=sys.stderr)
if problems:
print(f"FAIL:{len(problems)} 处(`{FEATURE}` 只许由 crates/presenter 的 dev-dependencies 打开)", file=sys.stderr)
return 1
print(f"ok: `{FEATURE}` 只出现在允许的位置(core 的声明 + presenter 的 dev-dependencies)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""测试通道守门(M169,r3:allowlist 改成**结构判定 + 默认拒绝**)。
`update-test-source` 让 `core::update::job::default_source` 可被两个环境变量指向任意本地目录,而更新
链路终点是**替换用户盘上的 exe**。「默认关 + 编译期不存在」是唯一可靠边界,所以「谁也没在流水线里
把它打开」必须是自动断言。
四条断言(任一不成立即 exit 1,CI 里红在 `.github/workflows/release-guard.yml`):
1. **workflow / 复合 action 里不许打开它**。唯一例外:`.github/workflows/ci.yml` 里**名为
`update-e2e` 的那个列表项**,且该项自己的文本必须带 `--test update_e2e_test`。归属按
`jobs.*.steps[]` 的**列表项**判(见 `split_items`),不是「最近一行 `- name:`」——后者会把追加到
文件末尾的无名步骤 / 无名 job 的步骤 / flow 风格步骤算进上一块(r2 复审 P2-1 实测三条全绿)。
**任何落不到具名步骤上的行(`jobs:` 之前的孤儿行、项外键、无名项)一律判红**:默认拒绝。
2. **不许「打开所有 feature」**:`--all-features`、`cargo hack --feature-powerset|--each-feature|--all`。
3. **不许设成默认**:`Cargo.toml` 里它只许是**自己的声明**(含跨行数组);进 `default` 或别的
feature 的依赖数组即红。
4. **扫描面逐项列举**(`WORKFLOW_GLOBS` / `SCRIPT_GLOBS` / `BUILD_ENTRY_GLOBS` / `CONFIG_GLOBS`):
含 `.github/actions/**`、`packaging/**` 递归子目录、`Makefile`/`justfile`/`Taskfile`、以及
`.cargo/config*.toml`(`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`,见
`scan_cargo_config`)。
注释剥离是**引号感知**的(`echo "#"; cargo build --features …` 里那个 `#` 不是注释)。抓不住的都是
**故意手打**或**运行期数据流**(完整清单见 `packaging/README.md` §14):变量拼接、两步 `$GITHUB_ENV`
交接(第二步没有字面量——静态扫描的固有边界)、feature 改名后接回、`build.rs` 里 spawn cargo、改名复用。
`--self-test` 把每条断言的**真实回归**注入临时副本(数据在 `packaging/tests/guard_vectors.json`),
断言该红的红、该绿的绿。守门自己抓不住回归时它会先红。
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import tempfile
from pathlib import Path
#: 被守的 feature 名(改名就得同步改本文件与各 `Cargo.toml`)。
FEATURE = "update-test-source"
#: 仓库根(本文件在 `packaging/` 下)与本文件自身(扫描时跳过:它必须写出 feature 名)。
ROOT = Path(__file__).resolve().parent.parent
GUARD = Path(__file__).resolve()
#: 扫描面(逐个列举,不用 `**` 全仓:那会把 `target/` 拖进来)。
WORKFLOW_GLOBS = (
".github/workflows/**/*.yml",
".github/workflows/**/*.yaml",
".github/actions/**/*.yml",
".github/actions/**/*.yaml",
)
SCRIPT_GLOBS = ("packaging/**/*.py", "packaging/**/*.sh", "packaging/**/*.ps1")
BUILD_ENTRY_GLOBS = (
"Makefile",
"justfile",
"Justfile",
"Taskfile.yml",
"Taskfile.yaml",
"crates/*/Makefile",
"packaging/Makefile",
)
MANIFEST_GLOBS = ("Cargo.toml", "crates/*/Cargo.toml")
#: cargo 配置:`--cfg feature="…"` 与 `rustc-wrapper` 都能在没有 `--features` 时打开这条通道(P2-2)。
CONFIG_GLOBS = (
".cargo/config.toml",
".cargo/config",
"crates/*/.cargo/config.toml",
"crates/*/.cargo/config",
)
#: 任意 wrapper 都能注入 `--cfg`,文本扫描看不见 ⇒ 一律判红。
WRAPPER_KEY = re.compile(r"^\s*(rustc|rustc-workspace)-wrapper\s*=")
#: 唯一允许出现该 feature 的地方:**文件 + 列表项名 + 必须同在的字符串**,三者不符即红。
ALLOWED_STEP = {
"workflow": ".github/workflows/ci.yml",
"name": "update-e2e",
"must_contain": "--test update_e2e_test",
}
#: 「打开全部 feature」的开关。`--all` 后面不许跟 `-`/字母(否则 `--all-targets` 会被误杀)。
ALL_FEATURES = (
re.compile(r"--all-features\b"),
re.compile(r"--feature-powerset\b"),
re.compile(r"--each-feature\b"),
re.compile(r"\bcargo\s+hack\b[^\n]*\s--all(?![-\w])"),
)
#: YAML 列表项(任意缩进);名字只认键位置上的 `name:`(块风格 / flow 风格)。
LIST_ITEM = re.compile(r"^(\s*)-\s+")
NAME_BLOCK = re.compile(r"^['\"]?name['\"]?\s*:\s*(.+)$")
NAME_FLOW = re.compile(r"^\{[^{}]*?\bname\s*:\s*([^,}]+)")
#: Cargo.toml 里「自己的声明」:行首 `update-test-source = [`。
DECLARATION = re.compile(rf"^\s*{re.escape(FEATURE)}\s*=\s*\[")
#: 块标量开头(`run: |` / `run: >-`):其后的更深缩进行是**内容**,不是键。
BLOCK_SCALAR = ("|", ">", "|-", ">-", "|+", ">+")
def strip_comment(line: str) -> str:
"""引号感知地剥注释:`#` 只有「行首或空白之后」且不在引号内时才开始注释。"""
quote = None
for index, char in enumerate(line):
if quote is not None:
if char == quote:
quote = None
elif char in "\"'":
quote = char
elif char == "#" and (index == 0 or line[index - 1].isspace()):
return line[:index]
return line
def dangerous_flag(code: str) -> str | None:
"""这一行有没有「打开全部 feature」的开关(返回命中的开关,便于写清原因)。"""
for pattern in ALL_FEATURES:
match = pattern.search(code)
if match:
return match.group(0)
return None
def _lines(path: Path) -> list[tuple[int, str]]:
return list(enumerate(path.read_text(encoding="utf-8").splitlines(), start=1))
def _name_of(key_indent: int, body: list[tuple[int, str]]) -> str | None:
"""列表项的名字:项内**同级**的 `name:` 键(键序无关;块标量内容不当键)。
「同级」= 项首行 `- ` 之后那一列(`item.end() == key_indent`,r3 复审 P2-1):更深缩进的
`- name:`(`with:` / `env:` 子列表、`run: |` 内容)**不算**项名。找不到、或出现两个**不同**的
`name:` 都返回 `None` ⇒ 落不到具名步骤 ⇒ 带 feature 就红(默认拒绝)。
"""
in_block = False
found: str | None = None
for _number, line in body:
text = line.strip()
if not text or text.startswith("#"):
continue
indent = len(line) - len(line.lstrip(" "))
item = LIST_ITEM.match(line)
if in_block and indent > key_indent:
continue
in_block = False
if item and item.end() == key_indent:
code = strip_comment(line[item.end() :]) # 项首行:` - name: x` → `name: x`
elif not item and indent == key_indent:
code = strip_comment(text)
else:
continue
for pattern in (NAME_BLOCK, NAME_FLOW):
match = pattern.match(code.strip())
if match:
value = match.group(1).strip().rstrip("}").strip("'\"")
if found is not None and found != value:
return None
found = value
break
if code.rstrip().endswith(BLOCK_SCALAR):
in_block = True
return found
def split_items(
lines: list[tuple[int, str]],
) -> tuple[list[tuple[str | None, list[tuple[int, str]]]], list[tuple[int, str]]]:
"""按 YAML **列表项**切块,返回((项名, 该项的行), 孤儿行)。
每个 `- ` 起一项;更深的行归当前项,**同级或更浅的非列表行结束当前项**(job 键 / `steps:` /
顶层键)。落不到列表项上的行是孤儿——不属于任何步骤,带 feature 就红。
"""
items: list[tuple[str | None, list[tuple[int, str]]]] = []
orphans: list[tuple[int, str]] = []
body: list[tuple[int, str]] = []
base, key_indent = 0, 0
for number, line in lines:
text = line.strip()
if not text or text.startswith("#"):
(body if body else orphans).append((number, line))
continue
indent = len(line) - len(line.lstrip(" "))
item = LIST_ITEM.match(line)
if item and (not body or indent <= base):
if body:
items.append((_name_of(key_indent, body), body))
body, base, key_indent = [(number, line)], indent, item.end()
continue
if body and indent > base:
body.append((number, line))
continue
if body:
items.append((_name_of(key_indent, body), body))
body = []
orphans.append((number, line))
if body:
items.append((_name_of(key_indent, body), body))
return items, orphans
def _reason(where: str, name: str | None, text: str) -> str | None:
"""`None` = 这一项被放行;否则返回「为什么红」的一句话(按最具体的那条给)。"""
if where != ALLOWED_STEP["workflow"]:
return f"`{FEATURE}` 只许出现在 `{ALLOWED_STEP['workflow']}` 的 `{ALLOWED_STEP['name']}` 步骤里(这里是 `{where}`)"
if name is None:
return f"这一项没有同级 `name:`,落不到具名步骤(默认拒绝);`{where}` 里只有 `{ALLOWED_STEP['name']}` 可以用它"
if name != ALLOWED_STEP["name"]:
return f"`{where}` 里只有名为 `{ALLOWED_STEP['name']}` 的步骤可以用 `{FEATURE}`(这一项叫 `{name}`)"
if ALLOWED_STEP["must_contain"] not in text:
return f"`{where}` 的 `{name}` 步骤少了 `{ALLOWED_STEP['must_contain']}`"
return None
def _flag_problems(where: str, body: list[tuple[int, str]]) -> list[str]:
problems: list[str] = []
for number, line in body:
flag = dangerous_flag(strip_comment(line))
if flag:
problems.append(
f"::error file={where}:{number}::{where} 用了 `{flag}`:它会把 `{FEATURE}` 一起打开"
)
return problems
def scan_workflow(path: Path, root: Path) -> list[str]:
"""扫 workflow / 复合 action:feature 只许出现在 allowlist 点名的那一项里。"""
where = path.relative_to(root).as_posix()
items, orphans = split_items(_lines(path))
problems = _flag_problems(where, orphans)
for number, line in orphans:
if FEATURE in strip_comment(line):
problems.append(
f"::error file={where}:{number}::{where} 这一行落不到任何具名步骤(默认拒绝);"
"官方发布产物里不许有测试通道"
)
for name, body in items:
problems += _flag_problems(where, body)
codes = [(number, strip_comment(line)) for number, line in body]
hits = [(number, code) for number, code in codes if FEATURE in code]
if not hits:
continue
reason = _reason(where, name, "\n".join(code for _number, code in codes))
if reason is None:
continue
for number, _code in hits:
problems.append(
f"::error file={where}:{number}::{reason};"
"官方发布产物里不许有测试通道(它能把更新源指向任意本地目录)"
)
return problems
def scan_code_file(path: Path, root: Path) -> list[str]:
"""扫脚本 / 构建入口 / cargo config:没有 allowlist,字面量或开关出现即红。"""
where = path.relative_to(root).as_posix()
problems: list[str] = []
for number, line in _lines(path):
code = strip_comment(line)
if not code:
continue
if FEATURE in code:
problems.append(
f"::error file={where}:{number}::{where} 打开了 `{FEATURE}`:"
"官方发布产物里不许有测试通道(它能把更新源指向任意本地目录)"
)
flag = dangerous_flag(code)
if flag:
problems.append(
f"::error file={where}:{number}::{where} 用了 `{flag}`:它会把 `{FEATURE}` 一起打开"
)
return problems
def scan_manifest(path: Path, root: Path) -> list[str]:
"""扫 Cargo.toml:feature 只许出现在自己的声明里(**含跨行数组**),不许进别的列表。"""
where = path.relative_to(root).as_posix()
problems: list[str] = []
in_declaration = False
for number, line in _lines(path):
code = strip_comment(line).strip()
if in_declaration:
in_declaration = "]" not in code
continue
if FEATURE not in code:
continue
if DECLARATION.match(code):
in_declaration = "]" not in code
continue
problems.append(
f"::error file={where}:{number}::{where} 把 `{FEATURE}` 写进了别的 feature 列表:"
"只许它是自己的声明(可跨行),不许进 `default` 或别的 feature"
)
return problems
def scan_cargo_config(path: Path, root: Path) -> list[str]:
"""扫 `.cargo/config*.toml`:`--cfg feature="…"` 与 `rustc-wrapper` 都能绕过 `--features`。
r3 复审 P2-2 实测:`RUSTFLAGS='--cfg=feature="update-test-source"'` 能让 `#[cfg(feature = …)]`
段在没有 `--features` 时编进来。`[build] rustflags` / `[env]` 靠 feature 字面量抓(走
`scan_code_file`);`rustc-wrapper` 抓不住(注入什么由程序决定)⇒ 一律判红,合法用途要 review 同意。
"""
problems = scan_code_file(path, root)
where = path.relative_to(root).as_posix()
for number, line in _lines(path):
if WRAPPER_KEY.match(strip_comment(line)):
problems.append(
f"::error file={where}:{number}::{where} 设了 rustc wrapper:wrapper 能注入任意 "
f"`--cfg`,静态扫描看不见它是否打开了 `{FEATURE}`(默认拒绝)"
)
return problems
def scan(root: Path) -> list[str]:
"""扫整个仓库,返回所有问题(空 = 绿)。"""
problems: list[str] = []
for patterns, scanner in (
(WORKFLOW_GLOBS, scan_workflow),
(SCRIPT_GLOBS, scan_code_file),
(BUILD_ENTRY_GLOBS, scan_code_file),
(CONFIG_GLOBS, scan_cargo_config),
(MANIFEST_GLOBS, scan_manifest),
):
for path in sorted({p for pattern in patterns for p in root.glob(pattern)}):
if scanner is scan_code_file and path.resolve() == GUARD:
continue
try:
problems += scanner(path, root)
except OSError as error:
problems.append(f"::error file={path}::读不到 {path}:{error}")
return problems
# ────────────────────────── 变异自测 ──────────────────────────
#: 自测数据(基线 + 向量)单独放一份 JSON:`packaging/tests/manifest.golden.json` 是同一手法。
#: 每个向量 = 一份「覆盖/新增的文件」;期望红的是真实回归,期望绿的是防误杀(合法写法不许红)。
#: 条目标签自带出处(`allowlist:` / `P2-1` / `P2-2` / `误杀` / `Cargo`),红的时候一眼看得出守哪条。
VECTORS_FILE = Path(__file__).resolve().parent / "tests" / "guard_vectors.json"
def _load_vectors() -> tuple[dict[str, str], list[tuple[str, dict[str, str], str]]]:
data = json.loads(VECTORS_FILE.read_text(encoding="utf-8"))
return data["baseline"], [
(item["label"], item["files"], item["expect"]) for item in data["vectors"]
]
def _scan_case(files: dict[str, str]) -> list[str]:
with tempfile.TemporaryDirectory(prefix="ull-test-source-guard-") as temp:
root = Path(temp)
for relative, text in files.items():
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return scan(root)
def self_test() -> int:
try:
base_files, vectors = _load_vectors()
except (OSError, ValueError, KeyError) as error:
print(f"::error::读不到自测向量 {VECTORS_FILE}:{error}", file=sys.stderr)
return 1
failures: list[str] = []
baseline = _scan_case(base_files)
if baseline:
failures.append(f"基线应当绿,实际红了:{baseline}")
for label, changes, expect in vectors:
files = dict(base_files)
files.update(changes)
hits = _scan_case(files)
if expect == "red" and not hits:
failures.append(f"{label}:注入了真实回归,守门却没红(抓不住)")
if expect == "green" and hits:
failures.append(f"{label}:应当放行,实际红了(误杀):{hits}")
if failures:
for failure in failures:
print(f"::error::{failure}", file=sys.stderr)
return 1
reds = sum(1 for _label, _changes, expect in vectors if expect == "red")
print(f"OK: 守门自测通过({reds} 条注入全红,{len(vectors) - reds + 1} 条基线/放行保持绿)")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=f"守门:官方发布产物里不许有 {FEATURE}")
parser.add_argument("--self-test", action="store_true", help="注入变异自测(该红的红、该绿的绿)")
parser.add_argument("--root", type=Path, default=ROOT, help="仓根(自测用)")
args = parser.parse_args()
if args.self_test:
return self_test()
problems = scan(args.root)
if problems:
for problem in problems:
print(problem, file=sys.stderr)
return 1
print(f"OK: 流水线与打包脚本都没有打开 {FEATURE}(默认关,发布产物不含测试通道)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Parse every workflow file with a **real YAML parser** (M190 r1 复审 P1-1).
Why this exists: a raw 0x01 byte slipped into `.github/workflows/release-build.yml` (a `sed` replacement
string that should have been `\\1`). GitHub refuses to parse such a file, so the whole run would not even
start — and no other guard noticed, because they all read workflow files as *text* (`check_release_semantics.py`
does `yaml_scalars()`, the file-size job only counts lines). A text scanner cannot see a control byte that
a YAML 1.2 reader rejects outright.
So: hand every `.github/workflows/*.yml` to PyYAML. Two checks, both cheap:
① it parses (real error message with position, not a mystery red);
② no C0 control character other than TAB/LF/CR is present (YAML 1.2 forbids them; parsers differ in
whether they reject, so this is the belt-and-braces half).
PyYAML is preinstalled on GitHub-hosted runners; if it is not, this script installs it. If that fails too
the gate **fails** rather than degrading to a text scan ("工具缺失也 fail" — same rule as the other guards).
python3 packaging/check_workflows_yaml.py
python3 packaging/check_workflows_yaml.py --self-test
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
WORKFLOW_DIR = Path(__file__).resolve().parent.parent / ".github/workflows"
ALLOWED_CONTROL = {0x09, 0x0A, 0x0D}
def load_yaml():
"""Import PyYAML, installing it once if the runner lacks it."""
try:
import yaml # noqa: PLC0415
return yaml
except ImportError:
print("PyYAML 缺失,尝试安装一次(pip install --quiet pyyaml)…", file=sys.stderr)
done = subprocess.run(
[sys.executable, "-m", "pip", "install", "--quiet", "pyyaml"],
capture_output=True,
text=True,
check=False,
)
if done.returncode != 0:
print(
"error: 装不上 PyYAML,YAML 门禁无法运行(门禁不降级)。输出:\n" + done.stdout + done.stderr,
file=sys.stderr,
)
raise SystemExit(2) from None
import yaml # noqa: PLC0415
return yaml
def control_characters(data: bytes) -> list[tuple[int, int]]:
"""`[(位置, 字节)]`:YAML 1.2 不许出现的 C0 控制字符(TAB/LF/CR 除外)。"""
return [(index, byte) for index, byte in enumerate(data) if byte < 0x20 and byte not in ALLOWED_CONTROL]
def check_file(path: Path, yaml) -> list[str]:
"""返回该文件没过的判据(空 = 通过)。"""
failures: list[str] = []
data = path.read_bytes()
for index, byte in control_characters(data):
line = data[:index].count(b"\n") + 1
failures.append(
f"::error file={path},line={line}::第 {line} 行含裸控制字符 {byte:#04x}(YAML 1.2 禁止;"
"常见成因是 sed/printf 把 `\\1` 写成了 0x01)"
)
try:
text = data.decode("utf-8")
except UnicodeDecodeError as error:
failures.append(f"::error file={path}::不是合法 UTF-8({error})")
return failures
try:
yaml.safe_load(text)
except Exception as error: # noqa: BLE001 - 解析器的异常类型随版本变,这里只关心「没通过」
first = str(error).splitlines()[0] if str(error) else repr(error)
failures.append(f"::error file={path}::YAML 解析失败:{first}")
return failures
def self_test() -> int:
"""注入一枚 0x01 到临时副本,断言门禁**会红**(否则这条门禁对任何输入都绿)。"""
import tempfile
yaml = load_yaml()
failures = 0
sample = "name: x\non: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: |\n echo hi\n"
with tempfile.TemporaryDirectory() as tmp:
good = Path(tmp) / "good.yml"
good.write_text(sample, encoding="utf-8")
if check_file(good, yaml):
print("FAIL: 合法 YAML 被判红(误杀)", file=sys.stderr)
failures += 1
bad = Path(tmp) / "bad.yml"
bad.write_bytes(sample.replace('"/\\1/p"', "").encode("utf-8") + b'x\n v=$(s/"' + bytes([1]) + b'/p)\n')
if not check_file(bad, yaml):
print("FAIL: 含裸 0x01 的 YAML 竟然通过(判据失效)", file=sys.stderr)
failures += 1
# 只让 PyYAML 判的那一半也要能红:把控制字节换成合法字符,但结构坏掉。
broken = Path(tmp) / "broken.yml"
broken.write_text("a: [1, 2\nb: 3\n", encoding="utf-8")
if not check_file(broken, yaml):
print("FAIL: 结构坏的 YAML 竟然通过", file=sys.stderr)
failures += 1
print("self-test:", "ok" if failures == 0 else f"{failures} 处失败")
return 0 if failures == 0 else 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="用真 YAML 解析器 parse 全部 workflow(M190 r1 P1-1)")
parser.add_argument("--self-test", action="store_true", help="自检:注入 0x01 / 坏结构必须红")
parser.add_argument("--dir", type=Path, default=WORKFLOW_DIR, help="workflow 目录(默认 .github/workflows)")
args = parser.parse_args(argv)
if args.self_test:
return self_test()
yaml = load_yaml()
files = sorted(args.dir.glob("*.yml")) + sorted(args.dir.glob("*.yaml"))
if not files:
print(f"error: {args.dir} 里一个 workflow 都没有(路径错了?门禁不降级)", file=sys.stderr)
return 2
failures: list[str] = []
for path in files:
failures.extend(check_file(path, yaml))
for line in failures:
print(line, file=sys.stderr)
if failures:
print(f"FAIL:{len(failures)} 条判据没过({len(files)} 个文件)", file=sys.stderr)
return 1
print(f"ok: {len(files)} 个 workflow 全部能被真 YAML 解析器 parse,且无裸控制字符")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+78
View File
@@ -0,0 +1,78 @@
# CI 运行时冒烟(Linux 真渲染一帧)
`packaging/README.md` §11 是本节的索引;细节在这里。为什么拆:本节要写阈值依据、失效模式与复跑口径,
塞回 README 会撞 400 行硬门禁——**两文件都必须 ≤400 行**,任一边再增长就再拆一节。
「编得过」≠「跑得起来」。`ci.yml` 的 `runtime-smoke` job 在 `ubuntu-latest` 上补第一份**真实图形环境**
运行证据:装 `xvfb`(虚拟 X 服务器)+ 软渲染依赖 + 字体,用 `xvfb-run` 把 GUI 进程拉起来,走既有的
进程内截图通道(`--software-renderer --screenshot <png> --data-dir <tmp>`)渲出一帧 PNG,再对**像素本身**断言。
## 1. 三道断言
`packaging/check_screenshot.py` 退出码 0 才算过;artifact 名 `runtime-smoke-screenshot`。
1. 进程退出码 0(截图通道失败会以非 0 退出,不留半张图);
2. PNG 存在、可解析、宽高 > 0(0 字节 / 非 PNG / 路径不存在都是硬失败,脚本 exit 1);
3. **非空白**:唯一色数 / 亮度标准差 / 强边缘占比三项同时过阈值。
为什么不能只看退出码:`--screenshot` 的 PNG 是进程自己编码写出的,只要流程走到 `take_snapshot` 就会得到
「合法 PNG + 退出码 0」;像素断言是**纵深防御**,守的是「进程活着、写出合法 PNG,但场景几乎没画」这一档。
本机可复现的两种失效都是硬失败(运行步就红,不必等像素断言):①完全没有字体 → fontique panic
(退出码 101,不产生 PNG);②不是软渲染(默认 femtovg)→ `take_snapshot is not supported by this
FemtoVG backend`(退出码 1)。这两条由 M62 review 实测复现(本 mission 未自己跑这两种破坏性实验)。
其余「渲染器出图但内容没上去」的情形本机暂无样本,属防御性设计,不宣称已复现。
## 2. 阈值与依据
`python packaging/check_screenshot.py --selftest` 可复现后三行:
| 样本 | 唯一色数 | 亮度σ | 强边缘占比 | 判定 |
|------|---------|-------|-----------|------|
| 本机 Docker 真截图(xvfb + 软渲染,1180x760,空数据目录) | 342 | 19.45 | 0.00761 | pass |
| Windows 历史截图(M45/M52,同一界面深浅两套,1180x760) | 529~699 | 20.29~27.46 | 0.0186~0.0433 | pass |
| 人为纯白 / 纯黑(空白帧) | 1 | 0.0 | 0.0 | fail |
| 最弱内容(1 像素黑边框 + 一条中线) | 2 | 19.09 | 0.00169 | fail(卡唯一色数) |
| **阈值** | ≥64 | ≥3.0 | ≥0.001 | — |
阈值留 **5~8 倍**余量(342/64 = 5.3×、19.45/3.0 = 6.5×、0.00761/0.001 = 7.6×):换主题/换字体不会误报,
空白帧一定红。
第三项用**强边缘占比**(水平相邻像素亮度差 ≥8 的比例)而不是平均亮度差:空数据目录的主界面 57% 是同一片
底色,平均差被稀释到 0.4,强边缘只数真正的边界,跨主题稳定(同一界面深浅两套差 2 倍,仍都过线)。
**这道闸门的下限 ≈「至少画出一条标题栏」**(review 实测切片):只画 1180×40 标题栏的切片(174 / 35.10 /
0.02623)仍 pass,右下 400×300 的切片也 pass。所以它证明的是「场景被真实渲染过」,**不证明界面元素齐全、
更不证明视觉正确**;视觉正确只能靠人眼看 artifact。
## 3. 覆盖哪个平台
只有 Linux x64(X11 + Xvfb + 软渲染)。
- **macOS**:`runtime-smoke-macos` 只在 `workflow_dispatch` 触发且 `continue-on-error: true`。官方 macOS
runner 处于 LaunchDaemon 上下文、无登录 WindowServer 会话,winit 大概率建不了窗口;本机没有 macOS
且 worker 禁 push,这条路径**尚无实测证据**,故不拿它卡 PR 门禁。首跑定去留:绿 → 删 `if` 与
`continue-on-error` 升常驻;红 → 错误原文进 progress,按 D26 归入 iced 重写再规划。
- **Windows**:不新增 job——`--software-renderer --screenshot` 已在本机验收里多次人眼验证(M45/M52)。
## 4. 本地复跑
```bash
bash packaging/linux-smoke-docker.sh . D:/tmp/ull-smoke
```
用 Docker(`rust:1-bookworm` + TUNA apt/rustup 源)复刻同一套步骤,截图落在 `<输出目录>/runtime-smoke.png`。
**输出目录别写 `/tmp/...`**:Git Bash 里 `/tmp` 是 MSYS 虚拟路径,`docker -v` 会把它当 Linux VM 内的路径,
结果是「脚本说成功、宿主机什么都没有」。脚本对 `out_dir`/`repo_root` 统一做 `cygpath -m` 归一,并回显真实
路径;没有 `cygpath` 的平台(Linux/macOS)按原样用。
诚实边界:本机容器是 Debian 12(glibc 2.36),官方 runner 是 Ubuntu 24.04(glibc 2.39),图形栈来源不同,
容器绿只证明「这条通道能跑」,官方 runner 首跑仍要单独看。
## 5. 已知跳过项与迁移风险
**跳过项**(D26 收窄,本条只做「框架无关的最小运行时冒烟」):Wayland(`weston --headless`)、
`--screenshot-page` 子页覆盖、gsettings 主题探测回退都不在本 job 内,随 iced 重写另行规划。
**迁移风险**:本 job 绑死 Slint 的截图通道(`Window::take_snapshot`)。**UI 迁 iced 后必须重新验证**——
iced 没有等价快照 API,届时要提供「离屏渲染出一帧 PNG」的等价通道,否则本 job 会静默退化成「能起进程」
而不是「能出图」(它本来也只证明「能出图」,不证明视觉正确)。
+53
View File
@@ -0,0 +1,53 @@
# 产物矩阵与版本来源(M145 拆自 `packaging/README.md`)
本文承载原 `packaging/README.md` 的 §1、§2,**节号沿用原编号**(全仓 `packaging/README.md §N` 这类引用照旧有效,
对照表在 [`packaging/README.md`](../README.md) 的索引里)。内容一字未改、只搬家:本行以上是 M145 新增的导航说明,
以下逐行取自原文件。
## 1. 产物矩阵
| 槽位 | 本版发布 | 产物文件名 | 形态 | 归档内容 |
|------|---------|-----------|------|---------|
| `windows-x64` | ✅ 发布 | `unlucky-launcher_<ver>_windows_x64.exe` | **裸 exe**(双击即用) | exe 单文件;三份合规文本作为并列 Release 资产 |
| `linux-x64` | ✅ 发布 | `unlucky-launcher_<ver>_linux_x64.tar.gz` | 单二进制归档 | `unlucky-launcher` + `LICENSE` + `NOTICE` + `README.md`;CI 在原生 `ubuntu-22.04`(glibc 2.35 地板)上构建,**并有同槽位的 xvfb 运行时门禁**(真渲一帧 + `[theme]` / `[backend] final` 日志断言 + 像素判定);真机验收证据见 `docs/linux-evidence.md` |
| `windows-arm64` | ❌ 不发布(构建取证中) | `unlucky-launcher_<ver>_windows_arm64.exe` | **裸 exe**(同 x64 策略) | 同上;CI 在**原生** `windows-11-arm`(Windows 11 Arm64 + VS2026 镜像)上构建,**M164 起编译前体检只取证、不再判定红绿**(此前五轮全红其实全红在那个误判的体检步),**红了不挡发布**(M142,见 §3) |
| `linux-arm64` | ❌ 已构建、不发布(运行时零证据) | `unlucky-launcher_<ver>_linux_arm64.tar.gz` | 单二进制归档(原生 `ubuntu-22.04-arm` 构建,与 x64 同 glibc 地板) | 同上;M184 起**默认就编**(`mode: ci`),但没有任何 ARM64 真机跑过一帧 ⇒ 不进 `PUBLISH_SLOTS`。解锁条件 = 真机渲出一帧 + 主题日志 |
| `macos-arm64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_arm64.tar.gz` | 单二进制归档 | 同上 |
| `macos-x64` | ❌ 不构建(未验证) | `unlucky-launcher_<ver>_macos_x64.tar.gz` | 单二进制归档 | 同上 |
| 每槽位 | 随所属槽位 | `ull_<ver>_<平台>_<架构>[.exe]` | **裸 CLI 文件**(独立资产,不进 tar.gz) | 零 UI 依赖的 `ull` 本体,进同一份 `SHA256SUMS.txt` |
| 全平台 | 仅已发布资产 | `SHA256SUMS.txt` | 校验清单 | 每行 `<sha256>␠␠<文件名>`(`sha256sum -c` 可直吃) |
| 全平台 | 仅已发布资产 | `manifest.json` | **更新清单**(D50,见 `README.md` §13) | 渠道 + 版本 + 各平台产物(`target`/`format`/`url`/`size`/`sha256`);生成器 `make_manifest.py` |
约定细节:
- **「不构建」= CI 默认根本不跑**(M139):macOS 那两档未验证,产物不给用户、构建配额也不花;要整表六槽位取证走
`gh workflow run release.yml -f all_slots=true`(§3)。发布白名单**是 `windows-x64` + `linux-x64`**(M184)。
「已构建但不发布」是独立的一档(`linux-arm64`、`windows-arm64`):它们 `mode: ci`、每轮都编,产物留在
`dist-<slot>` artifact 里取证,裁剪步会把它们剔出 Release。
(M164/M184)名单 ⇄ 产物两个方向都有守卫 `packaging/check_publish_slots.py`:声明了缺产物 ⇒ 红;有产物但没声明 ⇒
`::warning::` 明说「未发布、会被裁掉」;`mode: ci` 却本次没产物 ⇒ 也打 `::warning::`(那一档红了要看得见)。
- **产物命名**:`<名字>_<版本号>_<平台>_<架构>`,下划线分段、**不带实现细节字样**(不出现 `iced`);真相源是 `make_artifacts.py::asset_stem()`。
- **"单文件"指可执行文件本身零运行时依赖**(无 WebView、无 JRE、无 sidecar、无 DLL 旁挂);归档里的三份合规文本是许可义务、不是运行时依赖,Windows 因为"一个 exe 装不下文本",
把 `LICENSE` / `NOTICE` / `README.md` 作为 Release 的并列资产分发,条款同样随下载走。
- 归档解压后是**一个顶层目录** `unlucky-launcher_<ver>_<平台>_<架构>/`,用户不会解压出一堆散文件。
- **`ull` 是并列的裸资产,不塞进 tar.gz**:headless 用户要的就是「下一个小文件直接跑」;归档里放
LICENSE/NOTICE/README.md 是许可义务,而这三份本来就作为并列 Release 资产分发,裸 `ull` 不缺条款。
- `unlucky-launcher` 本体也认全部 CLI 子命令(M82),`ull` 不是第二个界面,而是**不链接图形栈**的同一套命令(差异见 §4 与 §7.4),两者不是重复品。
M118:GUI 的**构建产物**名是 `unlucky-launcher-iced`(`crates/app` 已被 workspace `exclude`,不再产出 `unlucky-launcher`);对外文件名仍是 `unlucky-launcher`,改名在 `make_artifacts.py` 落盘时(`LAUNCHER_BIN` 找产物 / `LAUNCHER_ASSET` 定对外名),所以下载链接 / `SHA256SUMS` / 本文矩阵不变。
- `<ver>` 不带前导 `v`(tag 是 `v0.2.2`,文件名是 `0.2.2`)——由脚本统一裁剪。
- UNIX 归档内 `mtime`/`uid`/`gid` 归零、权限固定(二进制 0755、文本 0644),
同源构建的 sha256 不受打包时刻影响。
- **架构不靠猜**:脚本打包前回读二进制的可执行文件头(PE 机器码 / ELF `e_machine` / Mach-O
`cputype`),与槽位声明的架构不符即失败——不会出现"名字是 arm64、内容还是 x64"的假产物。
## 2. 版本来源(唯一真相源)
版本号只有一个来源:**`Cargo.toml` 的 `[workspace.package] version`**。
- `<ver>` 由 `make_artifacts.py` 通过 `cargo metadata --no-deps` 读出(不是正则扒文件),
任何一个 workspace member 版本不一致时直接失败。
- 带 tag 构建时(CI)额外传 `--version "$TAG"`:脚本裁剪 `v` 后与 `Cargo.toml` 交叉校验,
**不一致即失败**——避免出现 tag 说 0.2.2、产物里写 0.2.1 的发布事故。
- `ull --version` 必报同一版本(脚本打包前跑一次冒烟 `ull --version` 断言版本串在其中),
这是 `env!("CARGO_PKG_VERSION")` 的实际输出,不是估算。
- 发布流程:先改 `Cargo.toml` 版本 + 提交 → `git tag v<ver>` → 推送 tag 触发流水线。
+139
View File
@@ -0,0 +1,139 @@
# 构建与 CI 流水线(M145 拆自 `packaging/README.md`)
本文承载原 `packaging/README.md` 的 §3,**节号沿用原编号**(全仓 `packaging/README.md §N` 这类引用照旧有效,
对照表在 [`packaging/README.md`](../README.md) 的索引里)。内容一字未改、只搬家:本行以上是 M145 新增的导航说明,
以下逐行取自原文件。
## 3. 怎么构建
### 本地(当前平台)
```bash
cargo build --release --locked # 产出 target/release/{unlucky-launcher-iced,ull}(M118:Slint 版不再进 workspace)
cargo build --profile fast --bins # 只是想快点看到画面/跑测试:快档(§6),产物在 target/fast/
python packaging/make_artifacts.py --out dist # 产物落在 dist/,含 SHA256SUMS.txt
python packaging/make_artifacts.py --version v0.2.2 # 顺带校验版本一致性
python packaging/make_artifacts.py --no-smoke # 跳过 ull --version 冒烟
```
脚本按宿主自动判定槽位(Windows x64 / Windows arm64 / Linux x64 / Linux arm64 / macOS arm64 /
macOS x64),其它组合直接报错退出——不在矩阵里的平台不出"凑合能跑"的产物。
交叉构建(例如在 x64 宿主上产 Windows arm64 产物)靠两个参数把"产物根"和"槽位"显式喂进去,
脚本不去猜宿主与产物是不是同一架构(**CI 不用这条路径**,见下):
```bash
rustup target add aarch64-pc-windows-msvc
cargo build --release --locked --target aarch64-pc-windows-msvc \
--bin unlucky-launcher-iced --bin ull # 不用 --bins:矩阵只发 GUI + ull;GUI 的构建产物名是 iced 的 bin(M118)
python packaging/make_artifacts.py --slot windows-arm64 \
--build-dir target/aarch64-pc-windows-msvc/release --out dist
```
`--slot` 与宿主槽位不一致时,`ull --version` 冒烟会**自动跳过并打印原因**(arm64 产物在 x64
宿主机上根本执行不了;拿"跳过"当通过是假证据,所以跳过永远带原因)。**CI 不走交叉**(M140):
`windows-arm64` 在原生 `windows-11-arm` 上编,宿主=产物架构 → 冒烟**真跑**;`linux-arm64` 也是原生
`ubuntu-22.04-arm` 构建 → 冒烟在 runner 上真跑(M184 起它默认就编,不再只在 `all_slots=true` 的取证跑里出现)。
### CI(默认跑四个槽位:Windows 两档 + Linux 两档;`all_slots=true` 才整表六槽位)
`.github/workflows/release.yml`:
- 触发:`push` tag `v*`;`workflow_dispatch` 只构建不发布,且多一个 `all_slots` 开关(**默认 false =
只跑四个槽位**,true = 整表六槽位都编一遍取证)。`inputs.prerelease` **只在 tag ref 上 dispatch
才可达**——Actions 页面的 "Run workflow" 只能选分支,要跑 tag 得 `gh workflow run release.yml --ref v0.2.3`。
分支 ref 的 dispatch 与 push 一样被 `release` job 的 `if: github.ref_type == 'tag'` 挡在发布之外,所以
「分支 ref 的 dispatch 只构建不发布」与「tag ref 上 dispatch 能选标记」是两条互不冲突的规则。
- 矩阵**由 `plan` job 按 `all_slots` 生成**(`matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}`:矩阵本身
不能带 `if`,而 `strategy` 可读 `needs`),槽位表只在该 job 里有一份:`windows-latest` x64 与
**原生** `windows-11-arm` arm64(M140;后者是公开仓免费的标准 arm64 runner,4C/16G)——两者都是 MSVC +
`-C target-feature=+crt-static`、产物都在 `target/release`,标 `mode: ci`(默认编);
(M184)`ubuntu-22.04`(glibc 2.35 地板)与 `ubuntu-22.04-arm`(原生 ARM64,同地板)**也标 `mode: ci`** ——
tag 推送默认就编这两档;只剩 `macos-latest` / `macos-15-intel` 是 `mode: probe`,**未验证、默认一个都不编**,
只有 `all_slots=true` 才进矩阵。
- **运行时证据决定发布,构建绿了不够**:`linux-x64` 进 `PUBLISH_SLOTS` 的依据是「release 产物真开窗渲出一帧」
(真机 WSLg + 截图 + `[theme]` / `[backend] final` 日志,实测输出见 `packaging/docs/linux-evidence.md`),
所以 `release-build.yml` 给**这个槽位**配了一条 xvfb 运行时冒烟步(`if: inputs.slot == 'linux-x64'`):
装 xvfb → `xvfb-run … --software-renderer --screenshot` 起 **release** 产物 → 断言 `[theme] source=` 与
`[backend] final=… result=started` 两行 → `check_screenshot.py` 判像素 → 上传首帧图。
`linux-arm64` **刻意没有**这条判据:arm64 上这条路一次都没验过,给它加一条没验证过的门禁就是造红。
- 每个槽位 `cargo build --release --locked`(**不传 `--target`**:每槽原生编译,交叉槽位没了)。arm64 槽位在编译前
**把工具链显式备齐**(M164):`rustup target add aarch64-pc-windows-msvc`、用微软自己的 `vcvarsall.bat arm64` 取
`LIB`/`INCLUDE`(写进本步环境与 `$GITHUB_ENV`)、用 vswhere 定位 arm64 `link.exe` 后显式导出
`CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER`(Hostarm64 原生优先、Hostx64 次之;`-find` 的斜杠/反斜杠两种形态都试)。
顺序不能反:只覆盖链接器而不给 `LIB`,撞上的就是 `LNK1181: cannot open input file 'kernel32.lib'`。
**`cmd //c` 不能直接收「带引号的 vcvarsall 路径」**:MSYS 会把 `"` 改写成 `\"`,cmd 的 `/c` 剥离规则接不住,
结果 `LIB`/`INCLUDE` 全空(M164 复审实测;`cmd //c` 只调**不含引号**的路径是好的)。故本步往 `$RUNNER_TEMP`
写一个 CRLF 的 wrapper `.bat`(`call "<vcvarsall>" arm64` + `set`),`cmd //c` 只收相对文件名——引号留在
`.bat` 内部由 cmd 自己解析,路径带空格也不受影响。
- **编译前体检已降级为「只取证」**(M164):M141/M142 的版本用 `find` 猜 `um/arm64/kernel32.lib` 在不在、猜缺就
`exit 1` —— v0.2.1~v0.2.3 五轮全红在**那一步**,`cargo build` 一次都没跑过(永远 skipped),而它跟自己打的快照
自相矛盾(`um/arm64` 明明在)。现在那一步只打印 vswhere 组件查询、各目录 `.lib` **计数**与 `link.exe` 候选,
永不 `exit 1`;**红绿只由编译器 / 链接器 / 静态 CRT 导入表门禁决定**。runner 实况:`windows-11-arm` 自 2026-09
是 Windows 11 Arm64 + **Visual Studio 2026** 镜像,`VC.Tools.ARM64` 与 `Windows11SDK.22621/26100` 都已装好。
- **发布白名单(`PUBLISH_SLOTS`,唯一开关)**:只有白名单里的槽位进 Release 与 `SHA256SUMS.txt`。**M184 起是
`windows-x64 linux-x64`**:齐全性检查是「名单内少一档就 fail」,所以 `linux-x64` 进来之后它的构建红了就挡住整次
发布 —— 这正是「声明为 ci 的槽位失败必须阻断发布」那条要求,判据落在 `check_publish_slots.py` 的 `--dist` 模式里
(`release` job 是 `always()`,构建矩阵里红一档不会让它跳过,挡住它的只能是那道守卫)。
`linux-arm64` 本轮**只构建不发布**:它没有任何运行时证据(没有 ARM64 真机跑过一帧,也没有 arm64 的 xvfb 冒烟),
**解锁条件 = 真机上真跑出一帧 + 主题日志**,之后才把键加进 `release-publish.yml` 那一行,再同步 §1/§9 与
`release-notes.md`。`windows-arm64` 同理仍未进名单(M164:它的五轮红全红在误判的体检步,等下一 tag 的 CI 证)。
**可逆且只是一行**:翻牌只改 `PUBLISH_SLOTS` 那一行,齐全性与 SHA256SUMS 自动跟着扩。
- **耗时(M184 估算,2 槽位 → 4 槽位)**:多出的两档是**并行**跑的(矩阵 `fail-fast: false`),所以墙钟增量 ≈
「`linux-x64` 那一档的时长(含新增 xvfb 冒烟步约 1~2 分钟)」与「`linux-arm64` 那一档的时长」取大者,而不是两者相加;
真正增加的是**并发作业数与总机时**(约 +2× 单槽位机时,公开仓 arm64 Linux runner 不额外计费)。
发布侧只多两个资产的裁剪/摘要/上传(秒级)。细节与逐项拆解见 `packaging/docs/linux-evidence.md`。
- **名单 ⇄ 产物两个方向都有守卫(M164,M184 扩)**:`packaging/check_publish_slots.py`,同一份代码两处跑 —— PR 阶段
(`.github/workflows/release-guard.yml`)断言「名单非空 / 每项都是槽位表里 `mode: ci` 的档」;发布侧
(`release-publish.yml` 的 `--dist` 模式,跑在**裁剪前**的全量集上)断言「声明了就得有 GUI 与 CLI 两个产物,
缺一个就红」,并把「有产物但名单没声明」打成 `::warning::` 明说未发布 —— M142 之后那个方向是静的(裁剪步只
`echo drop:`),一个真编出来的 arm64 会被就地删掉而没人知道。M184 补第三条:`mode: ci` 的槽位**本次连产物都没有**
(那一档构建红了)也打 `::warning::`,别只在表里少一行。`--self-test` 注入 11 条回归(含「声明的 `linux-x64` 构建红了
⇒ 必须红」「`linux-arm64` 有产物但没声明 ⇒ 只警告」「提前把 `linux-arm64` 写进名单 ⇒ 缺产物必红」),本地一条命令可跑。
- `release` job:下载全部 artifact → **发布名单守卫**(断言 + 未发布提示)→ 按白名单裁剪 → 重算 `SHA256SUMS.txt` →
用 `packaging/release-notes.md` 模板渲染支持矩阵 → 生成更新清单 `manifest.json` → `gh release create`
(**push tag 默认建稳定版**:不带任何标记)。
- **同一 tag 重跑幂等**:`gh release view` 命中已存在的 Release 时改走 `gh release upload --clobber`
+ `gh release edit`,覆盖资产与说明而不是再 `create`(`create` 遇已存在会失败,把流水线最后一步染红)。
- **预发布标记的语义表(M163,唯一真相源 `packaging/release_marker.py`)**:
| 触发 | create(新建 Release) | edit(Release 已存在) |
| --- | --- | --- |
| `push` tag `v*`(含其 Re-run) | **稳定版**(不带标记) | 不碰标记 |
| `workflow_dispatch` + `prerelease=true` | 预发布 | 显式改成预发布 |
| `workflow_dispatch` + `prerelease=false`(默认) | 稳定版 | 显式改成稳定版 |
空串必须是稳定版:`releases/latest/download/*` 只指向**非 prerelease** 的最新 Release,而自更新清单固定取
`releases/latest/download/manifest.json`(D50)——push tag 打成预发布,稳定通道就一直是 404(v0.2.2 至今
如此,M163 修的就是这条;**v0.2.2 那个 Release 保留为历史预发布不动**,稳定通道以 v0.2.3 为起点)。
edit 的空串是「不碰」而不是「改成稳定版」:同 tag 重跑**不会**把用户手工提成稳定版的 Release 翻回预发布。
要改已发版本的标记仍是两条等价路:① `gh workflow run release.yml --ref <tag> -f prerelease=false`
(落到 edit 分支显式断言)② 直接 `gh release edit <tag> --prerelease=false`(不经流水线,后续默认重跑不碰它)。
- **这条语义有守门(M163)**:`packaging/check_release_semantics.py`(CI 在 `.github/workflows/release-guard.yml`)
断言四件事:标记表逐条对(push tag 的 create 不含 `--prerelease`)、把 `release_marker.py --emit-bash` 的产物
**真灌进 bash** 读回两个数组、`release-publish.yml` 的可执行内容里没有 `--prerelease` 字面量、dispatch 默认值
`== false`(读 YAML 结构,不是 grep)。`--self-mutation` 再把「默认值改回 true」「塞回字面量」「表里 push 分支
改成预发布」注入临时副本,断言守门**每一项都红**、基线绿——守门自己抓不住回归时它先红。
- **M145 拆分(本节新增的唯一一段)**:上面所有口径都还成立,只是"在哪个文件里"变了 —— `release.yml` 只留
触发、`concurrency`、槽位表(`plan`)与两个 `uses:` 编排;`build` job 的 11 步逐字搬进
`.github/workflows/release-build.yml`(`workflow_call`,四个矩阵键换成同名 `inputs.*`),`release` job 的 6 步
逐字搬进 `.github/workflows/release-publish.yml`(同 `workflow_call`,`secrets.GITHUB_TOKEN` 改为调用方显式透传
的 secret)。触发(`on`)、`env`、顶层 `permissions`、`concurrency` 与 `plan` 的槽位表在 M145 搬家时**一字未动**;
后来只有 M163 动过 `on` 里 `prerelease` 的默认值(`true` → `false`,见上面那张语义表),M164 改过
`release-build.yml` 的 arm64 体检步与 `cargo build` 步、并在 `release-publish.yml` 裁剪前插了「发布名单守卫」
(于是本分支 8 步:checkout / download / **守卫** / 裁剪 / 重算 SHA256SUMS / manifest / 说明 / `gh release`;
搬家时 6 步、主线 7 步)。M184 又在 `release-build.yml` 里给 `linux-x64` 插了「运行时冒烟 + 上传首帧图」两步
(该文件 11 → 13 步,发布侧仍是 8 步)。改槽位仍只改 `release.yml` 那一处(`PUBLISH_SLOTS` 白名单在
`release-publish.yml`,翻牌时要改的仍是那两处)。
- **M149 微软登录 id 的烘焙**:`release-build.yml` 的 `cargo build` 步带 `env.ULL_MSA_CLIENT_ID`(值
`${{ secrets.ULL_MSA_CLIENT_ID }}`),`option_env!` 把它烘进二进制,于是发布产物装完即可登录。id **不进
git 文件**:真值放仓库 Secret,由 `release.yml` 的 `build` job 按名显式透传(可复用工作流拿不到未显式传入的
secret;这里刻意不用 `secrets: inherit`——那会把仓库全部 secret 一起透进被调方)。Secret 未录入、或在整个
Actions 之外求值时该表达式为空串,`client_id.rs` 把空白当「没配」⇒ 非官方构建只是不烘 id,不会拿空 id 去
联网;本地自烘仍走 `ULL_MSA_CLIENT_ID=<id> cargo build`。**别把这个 env 导出到跑 `cargo test` 的 shell**:
presenter 的 M132 入口锚会在「已烘焙 id」的构建里**故意红**(它要求一个两处都没有 id 的构建)。
- 发布说明模板(`release-notes.md`)的 "Known limitations" 里,微软登录一条已改为「开箱即用、env 可覆盖」。
Windows 为什么用 MSVC 静态 CRT 而不是本机这套 GNU 工具链:两者都属"标准工具链"(D21),
但发布产物要**不依赖 VC++ 可再发行包**,`+crt-static` 把 CRT 静态链进 exe,Win10+ 的 UCRT 本身是
系统组件,于是 exe 真正单文件可拷走。GNU 交叉产物在 CI 上还要拉 MinGW 链接器,收益为零。
@@ -0,0 +1,72 @@
# 发行合规、支持矩阵与已知边界(M145 拆自 `packaging/README.md`)
本文承载原 `packaging/README.md` 的 §8、§9、§10,**节号沿用原编号**(全仓 `packaging/README.md §N` 这类引用照旧有效,
对照表在 [`packaging/README.md`](../README.md) 的索引里)。内容一字未改、只搬家:本行以上是 M145 新增的导航说明,
以下逐行取自原文件。
## 8. 发行合规
- `LICENSE`(Apache-2.0 全文)与 `NOTICE`(含 Slint Royalty-Free 2.0 义务说明)随包分发,
三份文本在 UNIX 归档内、在 Windows 侧作为并列 Release 资产。
- Slint 的 RF 2.0 义务只跟 `v0.1.0` 那批资产:当时关于页有 `AboutSlint` 部件、选的是不触发 GPL 分支的 RF 分支
(见 `deny.toml` 的许可证白名单注释)。现役 iced UI 里没有、也不需要任何 Slint 部件。
- **M118 后这条义务只跟着已发布的 `v0.1.0` 资产走**:`crates/app` 已被 workspace `exclude`,发布矩阵里再没有链接 Slint 的二进制,新的 iced 版产物不链接 Slint;`deny.toml` 那条 RF-2.0 allow 因此暂时是死条目(不删,留给将来真有别的路径引入 Slint 时兜底)。
- `README.md`(英文主版)随包分发;中文版 `README.zh-CN.md` 留在仓库不进包(避免包体噪音)。
- 依赖许可门禁不变:`deny.toml` 白名单(Apache-2.0 / MIT / BSD / Zlib / BSL-1.0 / ISC / CC0 / Unlicense /
Unicode-3.0 / CDLA-Permissive-2.0 / Slint-RF-2.0)。发布任务不新增任何依赖。
- 发布产物不含游戏本体、不含 JRE、不含任何账号数据;`ull`/GUI 的数据目录在用户侧生成。
## 9. 支持矩阵(对外口径,与 release-notes 模板逐行一致)
| 平台 | 本版发布 | 级别 | 运行时要求 |
|------|---------|------|-----------|
| Windows 10/11 x64 | ✅ | 一等 | 无(UCRT 系统自带) |
| Linux x64 X11 | ✅ | 一等 | glibc 2.35+(`ubuntu-22.04` 原生构建即地板)、xkbcommon、fontconfig、libGL(或软渲染)、xcb |
| Linux x64 Wayland | ✅(同一归档;**Wayland 路径未验**) | 支持 | 上述 + wayland-client(XWayland 应用仍需 X11 那套) |
| Windows 11 ARM64 | ❌ 不发布(构建取证中) | 一等(启动器产物) | 无额外库;启动器原生运行,游戏本体走系统 x64 模拟层 + x64 JDK |
| Linux arm64 | ❌ 已构建、不发布(**运行时零证据**) | 一等(未真机验证) | glibc 2.35+(`ubuntu-22.04-arm` 原生构建,**与 x64 同地板**)、xkbcommon、fontconfig、libGL、xcb / wayland-client |
| macOS 12+(arm64 / x64) | ❌ 不构建 | 一等(未真机验证) | 无第三方库;未签名需去 quarantine |
| Windows 8.1/8/7 | — | 不支持 | 基线已上调(D21) |
| Android Termux + X11 | — | 试验级(不承诺) | CLI 与 GUI 均需源码自建;GUI 已在单机真机验证两条路径各一次(CPU 软渲染 + 自编 Mesa/turnip GPU 加速,配方见 `packaging/docs/termux-glibc-build.md`、证据见 `README.termux.md`),不在本发布矩阵内 |
`packaging/release-notes.md` 的 "Support matrix" 表与上表逐行对应(英文版同样带 published / not
published 一列,Termux 行标注 experimental)——改一边必须同步另一边,否则标题里的"一致"就是假话。
**M184 的翻牌(Linux x64 进发布名单,arm64 只构建)**:发布开关仍是 `release-publish.yml` 的 `PUBLISH_SLOTS`,
现在是 `windows-x64 linux-x64`;`release.yml` 槽位表里 `linux-x64` 与 `linux-arm64` 都是 `mode: ci`(tag 推送默认就编)。
两档的唯一差别是**运行时证据**:
- `linux-x64` 有:release 产物在 Linux 真机(Ubuntu 22.04 + WSLg 真窗口)渲出一帧、`[theme]` 与
`[backend] final=… result=started` 两行齐全、像素判定通过;`release-build.yml` 里还有一条**同槽位**的 xvfb
运行时门禁,每轮发布都重跑(证据与实测输出:`packaging/docs/linux-evidence.md`)。
- `linux-arm64` 没有:没有任何 ARM64 机器跑过它一帧,也没有 arm64 的 xvfb 冒烟。**解锁条件 = 真机(或原生
ARM64 runner)上真跑出一帧 + 主题日志**,之后才把键加进 `PUBLISH_SLOTS`。构建绿了不算解锁,别拿没跑过的包发人。
`windows-arm64` 为什么还没进名单(M164 遗留):它 v0.2.1~v0.2.3 的红**全部**红在一个编译前体检步(`find` 猜 SDK 的
`um/arm64/kernel32.lib`),`cargo build` 一次都没跑过;M164 把那个门禁降级为只取证、判定权交回编译器,但「能不能
出 PE」只能由**下一 tag 的 CI** 证明。名单 ⇄ 产物的两个方向都有守卫(`packaging/check_publish_slots.py`):
声明了缺产物 ⇒ 红(这正是「声明为 ci 的槽位构建红了就挡住发布」那条闸),有产物没声明 ⇒ `::warning::` 明说未发布,
`mode: ci` 却本次没产物 ⇒ 也打 `::warning::`(红了要看得见)—— 所以那次 CI 真出包之后,翻一行名单即可。
## 10. 已知边界 / 后续
1. **GUI 的 `--version` 已解决**(M82):现在命中子命令分发,输出与 `ull --version` 同一条版本 + 系统摘要后退出,
不再开窗;§7.2 里「无输出、8 秒后被 timeout 杀掉」那行是**改之前**的记录,留作对照。
2. **签名/公证**:Windows 无 Authenticode、macOS 无 codesign/notarize(都要证书/开发者账号),
当前以 SHA256SUMS + Release 说明作为完整性参考。
3. **macOS `.app` bundle / `.dmg`**:与单文件承诺冲突,暂不做;若用户要 Dock 图标再单开 mission。
4. **Linux musl 静态版**:可彻底摆脱 glibc 地板,但现役 iced 版(winit + wgpu)在 musl 上的可行性未验证,未纳入本矩阵。
5. **`ull` 已随包**(M82 起):每个槽位与 GUI 并列发一份裸 `ull`,进同一份 `SHA256SUMS.txt`,矩阵
完整性检查按「GUI 与 CLI 各自都要有」分开查(`release.yml`)。等 CI 首跑确认这条在真流水线上有效。
6. **本机 GNU 链接器的已知无害告警**:`cargo build --release`(GNU 工具链)会打一行 `warning: linker
stderr: corrupt .drectve at end of def file`(MinGW `ld` 的老式抱怨,不影响产物;CI 的 MSVC 槽位没有)。
7. **两个 arm64 槽位本机跑不了**:本机是 x64 Windows,既无 ARM 机器也无 MSVC 的 ARM64 链接库组合,
`windows-arm64` / `linux-arm64` 的**真实产物只能由 CI 出**(前者靠 `dumpbin -dependents` 的静态 CRT
门禁 + 架构校验,后者在 `ubuntu-22.04-arm` 上真跑 `ull --version` 冒烟);本机能做且已做的是脚本层
取证(§7.5:宿主判定注入、可执行文件头识别、把真 exe 的 PE 机器码改成 ARM64 当 stub 走完全流程)。
8. **Linux 上自更新只"能查不能装"**(M184 发布 Linux x64 之后才有意义):清单现在会带 `linux-x64` 的
`portable-zip` 产物,而 `ull update apply` 只做便携单文件 `.exe` 的自替换
(`crates/core/src/update/job.rs::ensure_self_replaceable`)——Linux 上它会带具名原因**拒绝**
("发布里的 linux-x64 产物是 portable-zip 形态,不能原地替换启动器 … 压缩包请解压后再用"),
不会半装坏。Linux 的更新方式仍是「下载新归档覆盖旧副本」。真要做 Linux 自更新(换目录里的文件再重启)
是另一件事,未规划。
+235
View File
@@ -0,0 +1,235 @@
# GPU 截图证据账(M249)
判据的**唯一实现**是 `packaging/check_gpu_shot.py`(零第三方依赖,19 条自测向量)。
样本是 M246 在本机的实测产物 `DevDocs/artifacts/gpu-probe/`(`DevDocs/` 被 `.gitignore:6` 忽略,
**图不进仓库**,所以这里只引它的读数,不引它的字节)。
一句话:这套判据把「这张图是 GPU 档出的」从**人读日志**变成**可复跑的退出码**,但它证明的是
「像素由哪条光栅化路径产出」,**不是**「用户看到的窗口长这样」。第 4/5 节逐条划线。
**CI 接线(M253)在第 8 节**:`--self-test` 无条件跑,真实产物判据只在有可用产物时跑;
**无 GPU 时显式跳过并写明原因,跳过与通过分开写**(跳过 ≠ 通过)。
## 1. 怎么跑
```text
python packaging/check_gpu_shot.py <stderr 日志> <PNG> # 正例:六条判据,0/1 退出
python packaging/check_gpu_shot.py <stderr 日志> --expect-refuse [PNG] # 负例:拒绝降级出图是否成立
python packaging/check_gpu_shot.py --self-test # 19 条向量(数据 tests/gpu_shot_vectors.json,脚手架 tests/gpu_shot_selftest.py)
```
退出码:`0` = 判据全过;`1` = 有判据没过(**逐条点名**,不只印 FAILED);`2` = 用法错(少参数 / 认不出的参数)。
判据不看日志的**请求值**(`[probe] renderer = wgpu` 只是意图),只看**结果行**(`[shot] … renderer=`)。
**取日志必须带 `ULL_GPU_LOG=info`**:`Selected: AdapterInfo { … }` 那一行是 wgpu 的 info 日志,
默认级别下根本不落盘(实测:a1b 那份 24 行 stderr 里 `^\[gpu\]` **0 条**,见 §5 第 2 条)。
## 2. 本机实跑退出码(M246 产物,逐条)
| 用例 | 命令(节选) | 退出码 | 判据怎么说话 |
|------|-------------|--------|-------------|
| A1 正例 GPU | `a1-gpu.stderr.log + a1-gpu-light.png` | **0** | 6/6 过:`renderer=wgpu`、`device_type=DiscreteGpu`(RX 6600 XT / Vulkan)、1180x760、`final=wgpu result=started`、唯一色 1856 / σ 33.21 / 强边缘 0.05778 |
| A2 正例 GPU(preflight) | `a2-preflight.stderr.log + a2-preflight-gpu.png` | **0** | 6/6 过(与 A1 字节相同的图,另带 `[gpu-preflight] ok type=DiscreteGpu`) |
| A4 正例 GPU(auto→shell) | `a4-auto.stderr.log + a4-auto-shell.png` | **0** | 6/6 过:`reason=auto-hardware-adapter` 也真的落到 wgpu |
| A3 CPU 档 | `a3-cpu.stderr.log + a3-cpu-light.png` | **1** | 3 条红:`renderer=tiny-skia`、无 `Selected` 行、`final=tiny-skia`(PNG 本身没问题:唯一色 2519 / σ 35.43) |
| A3b CPU 档重复 | `a3b-cpu-repeat.stderr.log + a3b-cpu-light.png` | **1** | 同上(重复性对照,结论一致) |
| A5 CPU 档 shell | `a5-cpu-shell.stderr.log + a5-cpu-shell.png` | **1** | 同上 |
| A6 负例,按正例口径判 | `a6-negative-refuse.stderr.log + a6-negative-refuse.png` | **1** | **6 条全红**(其中 3 条是「PNG 不存在」——负例本来就没图) |
| A6 负例,`--expect-refuse`(传 PNG 路径) | `a6-negative-refuse.stderr.log --expect-refuse a6-negative-refuse.png` | **0** | **3/3** 过:`via=refuse`、无出图行、目标 PNG 不存在 |
| A6 负例,`--expect-refuse`(不传 PNG) | `a6-negative-refuse.stderr.log --expect-refuse` | **0** | **2/2** 过(判据 9 不登记:`--expect-refuse` 的 PNG 参数是可选的) |
| A1b 日志 + A1b 图 | `a1b-gpu-repeat.stderr.log + a1b-gpu-light.png` | **1** | 见 §5 第 2 条:这份日志没开 `ULL_GPU_LOG=info`,**没有** `Selected` 行 ⇒ 单独看它无法自证适配器 |
| 串味:A1 日志 + A3 的 CPU 图 | `a1-gpu.stderr.log + a3-cpu-light.png` | **0** | **这是已知盲区**,见 §5 第 1 条(两张图同为 1180x760) |
A6 的负例形状(`WGPU_BACKEND=metal`):`Available adapters: []` → `wgpu 初始化失败(具名)` →
`拒绝降级出图` → `[backend] final=none result=failed … via=refuse`,退出码 1,**没有 PNG**。
## 3. 六条判据:每条抓得住什么、抓不住什么
判据 1–3 来自 M246 的实测口径,4–6 是为「日志与图对不上号」补的钉子。每条都能单独判红、单独打印。
| # | 判据 | 抓得住 | 抓不住 |
|---|------|--------|--------|
| 1 | **全部** `[shot] … renderer=` 行都是 `wgpu`(`set(renderers) == {"wgpu"}`) | 「其实走了 CPU 档」——CPU 档在这行写 `renderer=tiny-skia`(M97 起这行打的是**实际**后端,不是请求值,见 `crates/app-iced/src/renderer/mod.rs:52`) | 只证明这些行被打印过;日志被手工拼装、或从别的进程拷来的行,它分不清(判据只读文本);「一次运行里混了两种 renderer」(`>>` 拼接、`via=respawn` 降级)**一律判红**,但**同种 renderer 的多次运行拼在一起**看不出来(§5 第 7 条) |
| 2 | `Selected: AdapterInfo { device_type: X }` 且 `X ∉ {Cpu, Other}`;**取不到 `X` 一律判红** | 「wgpu 真建成了设备,而且选中的不是 WARP(`Cpu`) / Gl 兼容层(`Other`)」;与 `crates/app-iced/src/probe.rs:165-167` 的硬件判据同口径 | 选中的是**哪一块**卡不判(独显 vs 集显都算硬件);不看 `Available adapters` 那 12 条,只看 `Selected` 那条 —— 也就抓不住「枚举里有独显、实际选了集显」之外的更细的驱动行为;**多行 Debug / 字段缺失**时不知道真值是啥,只按「取不到即判红」处理(宁可假红:iced 换成 pretty-print Debug 时真 GPU 图会被判红,得回来改正则) |
| 3 | PNG 能解码且像素数 > 0 | 0 字节 / 半截写入 / 根本不是 PNG(签名、IHDR、IDAT 逐层查) | 只管「能解出来」,不管内容对不对(那是判据 6 的事);不解调色板与隔行 PNG(沿用 `check_screenshot.py` 的同一条限制) |
| 4 | PNG 宽高 = 日志 `[shot]` 声明的宽高 | 拿错文件的第一步:日志说 1180x760、图是别的尺寸 | **同尺寸互换抓不住**(见 §5 第 1 条);分辨率和内容是否同一帧也抓不住 |
| 5 | `[backend] final=wgpu result=started` | 后端结论行与判据 1/2 自相矛盾(例如 `final=none … via=refuse`) | 需要 `ULL_ACCEPTANCE=1` 才有这行;只看这一行的话,A6 那种「一行都没出图」和「出了图但日志没写」分不清 |
| 6 | PNG 过 `check_screenshot.py` 的非空白阈值(唯一色 ≥64 / 亮度σ ≥3.0 / 强边缘 ≥0.001) | 「进程起来了、PNG 合法、但一个像素都没画」的空帧 | 阈值很低(这是它的设计:跨主题/字体不误报),**不判视觉正确性**,也不判元素齐全 |
`--expect-refuse` 的三条(负例口径):`via=refuse` 存在、**没有**任何 `[shot]` 出图行、目标 PNG 不存在。
它证明「该拒绝时真拒绝了」,**不证明**「GPU 能出图」——两者互为对照,缺一个都能蒙混过关。
### 3.1 判据对「行为被改坏」敏感(变异自证)
`--self-test` 的向量在 `packaging/tests/gpu_shot_vectors.json`,每条的 `expect_fail` 是**期望的失败判据
清单,逐条对**。三个变异都能被抓住(本轮 r1 又补了判据 2 的「取不到即红」):
| 变异 | 抓它的向量 | 结果 |
|------|-----------|------|
| `DISALLOWED_DEVICE_TYPES` 里删掉 `Cpu`(= 允许 WARP 当 GPU) | 「renderer=wgpu 但 Selected 是 Cpu」 | 由红转绿 ⇒ 清单对不上 ⇒ `--self-test` 退出 1 |
| 删掉判据 1(不再要求 `renderer=wgpu`) | 「删掉 [shot] renderer 行」、「日志里混入第二种 renderer」 | 由红转绿 ⇒ 清单对不上 ⇒ `--self-test` 退出 1 |
| 判据 2 不再把「取不到 device_type」当失败(恢复旧写法) | 「Selected 换成多行 Debug」、「`Selected: AdapterInfo {` 空体」 | 由红转绿 ⇒ 清单对不上 ⇒ `--self-test` 退出 1 |
## 4. 这份证据**能**证明什么
逐条都能指到「哪个文件 + 哪条判据过了」:
1. **字形栅格与文字边缘的抗锯齿是 GPU 路径给的。** `a1-gpu-light.png` 的 1180×760 像素由 wgpu/Vulkan
在 RX 6600 XT 上光栅化(判据 1+2+5 全过,`Selected` 是 `DiscreteGpu`),而同一屏的 CPU 档(`a3`)
被同一套判据判红。⇒ 「12px 规格文本的覆盖率采样来自 MSAAx4 的那条路」这件事,是**图+日志**一起证的。
2. **面板 α 与玻璃只有 GPU 档能出。** CPU 档在代码层就把它们强制掉了:`crates/app-iced/src/pages/
appearance/alpha.rs` 的 `panel_is_forced_opaque(Backend::Cpu) == true` ⇒ `effective_panel_alpha`
恒 1.0、`effective_glass` 恒 false。所以只要是**真的** GPU 档出的图,它拍到的面板 α/玻璃是产品
在 GPU 档下的真实合成结果,而不是「CPU 兜底也能喂出来」的近似。
3. **两个后端在同一屏上的一致性是可复核的。** `--diff` 的读数(M246):gallery `differing=51055/896800
(5.69%) max_delta=200 best_shift=(0,0)`、shell `19088/896800 (2.13%) max_delta=75 best_shift=(0,0)`;
差异 **99.3%** 落在亮度边缘上、`delta>32` 的 9301 像素连成 3002 块(最大 83 px);两个后端各自连跑
两次 PNG **字节相同**(`52d4c1d6e464da16` ×2 / `10696a043bd9e7b9` ×2)。⇒ 「几何没有平移、差的是
边缘覆盖率」这个结论成立,5.69% 不是抖动。
4. **`--renderer=auto` 不是「悄悄走 CPU」。** A4 的 `reason=auto-hardware-adapter` 与 `Selected:
DiscreteGpu` 同时成立,判据 0 退出。
5. **断开的 RDP 会话也能出 GPU 图**(M246 的现场条件:`quser` 显示 `2 断开`)。⇒ 「断连 ⇒ GPU 截不了图」
这个说法不成立;成立的是 M245 §4 的「断连打折首帧耗时与整窗 α,不打折像素内容」。
6. **「该拒绝时真拒绝」有机器判据。** A6 的 `via=refuse` + 无 PNG + 退出码 1 由 `--expect-refuse` 判 0。
## 5. 这份证据**不能**证明什么(逐条,别把「没证」读成「没问题」)
1. **图与日志的绑定只到「尺寸」一级。** 同一尺寸的两张正经图互换,本脚本判绿(实测:A1 日志 + A3 的
CPU 图 = 退出码 **0**)。要堵这个口得让产品把 PNG 的 sha256 打进日志(改 Rust,不在本 mission)。
⇒ 现有用法是「一次运行、当场取日志与图」,**不是**「从一堆历史产物里随便挑两个配对」。
2. **日志级别不够时判据 2 无从判定。** `Selected: AdapterInfo` 是 wgpu 的 info 级日志:a1b / a5 那两轮
没带 `ULL_GPU_LOG=info`,两份 stderr 里 `^\[gpu\]` 各 **0 条**。a1b 的「合格」来自它与 a1 **字节相同**
(`52d4c1d6e464da16`),**不是**来自它自己的日志 —— 所以本脚本对 a1b 判红是**如实**(这份日志单独
确实证不了适配器),不是误报。
3. **首帧 / 帧率不能证。** 全部 `[perf]` 都是墙钟,且冷热差距极大(A1 首次 `probe=1281ms`、`font=749ms`;
后续进程 `584/587ms`、`55~57ms`)。A1 `3661ms` vs A3 `3146ms` **不能**读成「GPU 比 CPU 慢」。
4. **整窗 α 与圆角观感不能证,任何截图都不行。** `window::screenshot` 读的是渲染目标,而整窗 α
(`LWA_ALPHA` 字节)与圆角(`DWMWA_WINDOW_CORNER_PREFERENCE`)由 **DWM 在合成时**施加,不在渲染
目标里 —— 同一份判据在 Windows 上拍到的是「DWM 之前」的像素。观感只能人眼看真显示器。
5. **「窗口被 DWM 合成过」这件事没证。** 图是渲染目标,不是屏幕。DWM 是否真把这帧合成上屏、有没有掉帧、
有没有被别的窗口遮挡,这张图一概不知道。
6. **换机器不成立。** 全部证据来自一台机器(RX 6600 XT / Raphael iGPU、12 个 adapter、RDP 断开会话)。
`VirtualGpu` 会不会被判成硬件只在**代码层**确认(`probe.rs:165-167` 会放行),**没在真 `VirtualGpu`
适配器上验过**(本机 12 条里一条都没有)。
7. **同种 renderer 的多次运行拼在一起,无法分辨图属于哪一次。** 判据 1 只挡住「混入**异种** renderer」
(wgpu + tiny-skia 拼一起);两次都是 `renderer=wgpu` 的日志 `>>` 到同一个文件时所有行都自洽,
判据 4 又只比尺寸 ⇒ 两张同为 1180×760 的 GPU 图互换仍然判绿。要堵它得把 PNG 的 sha256 / 运行 id
打进日志(改 Rust)。⇒ 一次取证 = 一份干净的 stderr,别往同一个文件里续写。
8. **交互启动那一支没验。** A6 验的是「取证启动(`--screenshot`)下拒绝降级出图」;交互启动的
`via=respawn`(换进程降级)要求真窗口,本轮没开。
9. **dark 主题、其它屏、`ULL_WINDOW_SIZE` 变尺寸、macOS / Linux** 全部没跑:只有 gallery + shell 的 light。
## 6. 历史 `--diff` 基线:**永久不可复核**
`DevDocs/00-债务台账-p1.md:29` 记着一条 `differing=8134/896800 (0.91%) max_delta=60 best_shift=(0,0)`,
M246 本机实测是 **5.69% / 200**(gallery)与 **2.13% / 75**(shell)。两个数字**不可比**,原因是记录本身
缺项:那条没有 commit、没有屏幕、对应图(`DevDocs/artifacts/m187/`)**已丢失**。
⇒ 这条基线在任何将来都**无法复核**:没有图就没有分母,没有 commit 就没有树状态,重跑也只会得到新读数。
唯一还能比的是**几何判据**(`best_shift=(0,0)`),它一致。别把 0.91% 当「回归阈值」用;本账不承认它是基线。
## 7. 没验的部分(M249 自己)
- **M249 当时没接进 CI**:那一轮 `.github/workflows/**` 一行未动(接 CI 是策略决定:runner 上有真 GPU,
还是接受「跑不起来就跳过」)。**M253 已接上**,见 §8;本节其余条目原样保留。
- **没在任何非本机环境跑过**:Windows + 上述单卡 + RDP 断开会话之外,Linux/macOS/其它 GPU 全没跑。
- **没改 Rust**:判据只读现有日志格式。若产品将来改了 `[shot]` / `Selected:` 的行格式,这里的正则
会同时失效(`--self-test` 用的是**按现格式手拼**的合成日志,抓不到格式漂移——它守判据,不守格式)。
- **没验 `--diff` 阈值化**:本文第 4 节引的是 M246 的读数,本 mission 没有重跑 `--diff`。
## 8. CI 接线(M253):无 GPU 时显式跳过,**跳过 ≠ 通过**
判据靠 **`.github/workflows/gpu-evidence.yml`** 跑(`push: main` / `pull_request` / `workflow_dispatch`,
以及 `workflow_call`:日常线 `dev` 经 `dev-guards.yml` 复用同一份,见下)。
**为什么新开一个文件而不是并进 `guards.yml`**:`guards.yml` 已是 399 行、红线 400(`file-size` 那道门
就扫 `*.yml`),挂一个 job 必越线;`release-build.yml` 正好 400 行、动它得先拆。与 `release-guard.yml`
当初单独成文的理由逐字相同。
**日常线 `dev` 也跑**(M253 r1 P2-1):`dev-guards.yml` 里多一个
`gpu-evidence: uses: ./.github/workflows/gpu-evidence.yml`。`dev-guards.yml` 文件头自己写着
「门禁存在但日常线上永不运行 = 空跑的门禁,长期等于没有」,而这条 workflow 原先只在 `main`/PR 触发,
直推 `dev` 时**一次都不跑**。dev 上 `evidence` 照样显式跳过(无 GPU、无产物),所以 dev 拿到的是
`selftest` 那一半:19 条向量 + 三个退出码 + 接线自检。**刻意不加**「只跑自测」的开关 —— 那会给这道
门禁多开一个「可以合法关掉真判据」的旋钮。
三个 job,各管一件事:
| job | 什么时候跑 | 它的结论代表什么 |
|-----|-----------|-----------------|
| `selftest` | **无条件**(不需要 GPU、不需要真产物) | 「尺子合格」:19 条向量 + 三个退出码 0/1/2 用真 CLI 走一遍 |
| `probe` | 无条件,只探测、不判定 | 工作区里有没有可判的产物;没有就把原因写进 job summary 并打 `::warning::` |
| `evidence` | `if: needs.probe.outputs.available == 'true'` | 「这张图真是 GPU 档出的」:`check_gpu_shot.py` 的退出码(0 过 / 1 判据不过 / 2 用法错)即结论 |
**跳过与通过分开写**:没有可用产物时 `evidence` job 的 `if:` 不成立 ⇒ 它在 Actions 里是**灰色
skipped**,不是一个绿色 ✓;同时 `probe` 打出(本地手模拟的原样输出,见 §8.1):
```text
GPU 证据:无硬件适配器(/dev/dri 下没有 renderD*,也没有可用的 nvidia-smi),且工作区里没有可用的样例产物
(约定路径要求 stderr 日志与同次运行的 PNG 同时存在;DevDocs/ 被 .gitignore 忽略,图不进仓库;event=push),
跳过真实产物判据(判据实现自测已过:那证明「尺子合格」,不证明「这张图是 GPU 出的」)
```
外加一条 `::warning::GPU 证据:跳过(…)—— 本次运行没有判过任何真实产物,不构成「GPU 证据已验证」`。
`available` 同时作为 job output 交给 `evidence`,所以「跳过」与「通过」在数据流上也是两条路。
四条路径的口径:
| 情形 | 结果 | 为什么这么定 |
|------|------|-------------|
| 工作区里有 `DevDocs/artifacts/gpu-probe/gpu-shot.stderr.log` + `gpu-shot.png`(约定成对) | `available=true` ⇒ `evidence` 真判 | 一次取证 = 一份干净的 stderr + 同次运行的那张图(§5 第 1/7 条);**只给一半仍按跳过**(半套产物不算可用) |
| `workflow_dispatch` 填了 `shot_log` / `shot_png` | 同上 | 显式点名 ⇒ 真判 |
| 点了名但**文件不存在**(或正例没给 PNG) | **红**(`::error::` + exit 1) | 「明确说要判、东西不在」若能退化成跳过,就又是一次假绿 —— 有意为之的输入缺失是错误,不是 skip |
| 托管 runner 常态(无 GPU + 无产物) | 跳过 + 原因 + 黄条 | 见上;托管 runner 本来就没有硬件适配器 |
**在 CI 里怎么让它真判**(r1 复审 P2-2:原先 §8 说的「把产物放到 `DevDocs/`」在 CI 里没人做得到 ——
**checkout 只拿已跟踪文件**,而 `/DevDocs/` 在 `.gitignore:6` 里,也没有 `upload/download-artifact` 环节):
1. 把一次取证的那一对产物**提交进你打算 dispatch 的那个 ref**:`git add -f
DevDocs/artifacts/gpu-probe/gpu-shot.stderr.log DevDocs/artifacts/gpu-probe/gpu-shot.png`(`-f` 是必需的,
否则被 ignore);
2. 对该 ref 跑 `workflow_dispatch`,或干脆让它走 `push` / PR —— `probe` 会认这对约定路径,`available=true`;
3. 不想提交到 `DevDocs/` 的话,把两个文件放到任一**已跟踪**目录,再用 dispatch 的
`shot_log` / `shot_png` 指过去(那两个输入只认「checkout 后确实存在的路径」)。
就地往 workspace 里拷文件(不提交、靠上一步的产物)**在这个设计里做不到**:没有 artifact 传递环节。
**接线自检的覆盖面**:`packaging/tests/gpu_shot_ci_selftest.py` 用 **10 条判据 + 33 条变异向量**
(r1 复审表 20 条 + 1 条补充 + r2 复审 12 条)把上面这些口径钉住,逐条见
`packaging/tests/gpu_shot_ci_vectors.json`:
1. 五步按 `id:` 必须在场且无条件(`vectors`/`exitcodes`/`wiring`/`detect`/`judge`),`exitcodes` 还得真断言 0/1/2;
2. 自测步必须**指向本脚本且是单条命令**(`|| echo 先忽略` 无从下手);
3. **解析 `if:` 表达式后求值**判「判定挂门」(`available` 取 `'true'`/`'false'`/`''` 必须真/假/假,认不出即红);
4. 两种调用形态都在场,且产物来自 `needs.probe.outputs.*`;
5. **判据步真跑两次**(正例必须 0、CPU 档必须非 0,`bash -e` 与裸 bash 两种模式都跑);
6. 跳过路径**实跑 7 种输入**;
7. `continue-on-error` 按 YAML 布尔语义判(`'true'` / `${{ true }}` 都算开启);
8. 吞码标记表(`|| true` / `; true` / `|| exit 0` / 裸 `exit 0`,兜底用);
9. `probe.outputs.*` 必须逐个绑到 `${{ steps.detect.outputs.* }}`;
10. `on:` 必须同时有 `push`(含 `main`)/`pull_request`/`workflow_call`。
**它不覆盖**:连判据一起改(它不自守);`if:` 的 GitHub 语义(那是本脚本的小求值器模拟的);真 Actions 里的
job skip / required check 行为;判据 4 的「合法调用替换」(后果是假红不是假绿);`exitcodes` 见证步自己的
断言被削弱;判据 6 那 7 种输入之外的新分支。
**载体依赖**:整个 `gpu-evidence.yml` 被删掉/替换后,**没有任何东西会跑这个自检** —— 守门人住在被守对象
自己的文件里(`guards.yml` 已经 399/400 行,塞不进去)。
本文件**不宣称**「不可能被改成总是绿」:r1 那一版这么写过,实测 19 条变异漏 11 条;r2 那一版
(6 条判据)实测 40 条新变异漏 11 条。所以这里只写**能复跑的封闭清单**(10 类 / 33 条向量)。
**这条门禁自 2026-11-11 起停止加固**:再往下是「守卫守守卫守守卫」,边际收益很低;要扩覆盖面必须由
人类新开 mission。
### 8.1 这一节**没验**的部分
- **CI 上从没真跑过**:本 workflow 是本地写的,**一次 GitHub Actions 运行都没有**(未 push)。
本地验的是两件事,且都**已固化成每次自检都重跑的功能断言**:① `detect` 的 `run` 原文跑 7 种输入
(跳过 / 约定成对 / 约定半套 / dispatch 成对 / 点名不存在 / 正例缺 PNG / 负例 —— 判据 6);
② `judge` 的 `run` 原文跑两次(正例必须 0、CPU 档必须非 0,`bash -e` 与裸 bash 各跑一遍 —— 判据 5)。
**这不是 CI 实测**:`if:` 求值、job skip 在 UI 上的呈现、`needs` 的输出传递在真 Actions 里仍未跑过。
- **没在真 GPU runner 上跑过**:`evidence` 的真判定只在本地用合成向量走过(0 与 1 都到达过),
「真产物 + 真 GPU runner」这个组合没实测。
- **没验「跳过是否满足 required status check」**:本 workflow 没挂成必需检查;GitHub 对 skipped job
与 required check 的交互口径没实测,别把这一节读成「跳过也能当门过」。
- **自检脚本自身的边界**在它自己的 docstring 里(不防「连判据一起改」;`!` 不在支持的表达式语法里,
出现即红;只有 `probe` 那一步是功能断言,`vectors`/`exitcodes` 两步只做结构断言)。
+208
View File
@@ -0,0 +1,208 @@
# Linux 槽位的证据账(M184)
本文是「Linux 到底验过什么、没验什么」的**唯一账本**。M184 把 `linux-x64` 写进了 `PUBLISH_SLOTS`(发布),
把 `linux-arm64` 留在「构建但不发布」,两者都不是凭感觉:下面按「已有哪些证据 → 本轮补的实测 → CI 增量 →
仍然没验的部分 → 解锁条件」逐条记账,任何一条被推翻都要回来改这里与 `compliance-support-boundaries.md` §9。
## 1. M184 之前的证据盘点
| 证据 | 位置 | 覆盖了什么 | 没覆盖什么 |
|------|------|-----------|-----------|
| xvfb 运行时冒烟(每次 PR/main) | `.github/workflows/ci.yml` 的 `runtime-smoke` | `ubuntu-latest`(x64)上装 xvfb + 软渲染依赖 + 字体,`xvfb-run … --software-renderer --screenshot` 真渲一帧,`packaging/check_screenshot.py` 对**像素本身**断言(唯一色数 / 亮度 σ / 强边缘占比),截图作为 artifact 留证 | 只有 x64;用的是 **debug** 档(`cargo build --bin unlucky-launcher-iced --locked`),不是发出去的那个 release 产物;不跑 GPU 通路;不验 Wayland;不校验 `[theme]` / `[backend] final` 日志行;没有 arm64 |
| 本地复跑脚本(开发机) | `packaging/linux-smoke-docker.sh` | 在 `rust:1-bookworm`(**Debian 12 / glibc 2.36**)里逐步复刻上面那个 job;M131 真跑过一次(2026-11-05):`exit=0`、截图 1180×760、`[ok] 截图非空白` | 基础镜像是 Debian 而不是 runner 的 Ubuntu;仍是 debug 档;同样没有 arm64 |
| 编译期门禁复跑 | `packaging/linux-check-docker.sh` | 在 Linux 上跑 `clippy --workspace --all-targets -- -D warnings` 与 `test --workspace --all-targets`(M144 那两条只能在 Linux 复现的红) | 与运行时无关,只证明「编得过、测得过」 |
| 阈值依据 | `packaging/ci-runtime-smoke.md` | 真实帧与纯色帧的实测数值(唯一色 342~2352、σ 19.45~27.46、强边缘 0.0076~0.0433)都在这里,阈值取在两者之间 | 标定样本来自 x64 软渲染 |
| `ull --version` 冒烟 | `packaging/make_artifacts.py::smoke_version` | 槽位与宿主一致时**机制上会真跑** CLI 并断言版本串(`slot != host_slot()` 才跳过)。`linux-x64` 侧本轮**真跑过**(见 §2 `smoke: ull --version -> 0.2.3 (ok)`) | **机制存在 ≠ 有过一次运行**:截至本分支,仓库里找不到任何一次 `linux-arm64` 成功出包的记录(`git grep -rn "all_slots" DevDocs/` 零命中),所以 **arm64 侧运行时证据为零** —— 连这条 CLI 冒烟都没在 arm64 上真跑过。GUI 一帧更没有 |
**glibc 地板怎么定的**:槽位表把 Linux 两档钉在 `ubuntu-22.04` / `ubuntu-22.04-arm`,**不跟 `latest` 走**
(`release.yml` 的槽位表注释:跟 latest 走地板就会随镜像漂移)。Ubuntu 22.04 ⇒ glibc **2.35**,这就是
`README` / §9 写的「glibc 2.35+」的来源,也是本轮真机验收为什么选 Ubuntu 22.04 的 WSL 发行版:**验证环境的地板
与发布产物的地板一致**,在更新的发行版上验等于放宽了条件。
**一处诚实记录**:M184 的任务描述里提到「曾用过 WSLg 开真窗口跑首帧」。本仓库里查不到那次留下的任何文档或
脚本痕迹(`grep -rn WSLg` 在本轮改动之前零命中),所以下面这一节是**重新做了一遍并留证**的,不是转述。
## 2. 本轮实测(WSLg 真机,linux-x64 release 产物)
**环境**(2026-11-08,`wsl.exe -v` / 发行版内自报,逐字取自实测输出):
```
Linux 6.6.114.1-microsoft-standard-WSL2 x86_64 # WSL 2.7.3.0,WSLg 1.0.73
PRETTY_NAME="Ubuntu 22.04.5 LTS" # == 发布槽位 ubuntu-22.04 的同一代
ldd (Ubuntu GLIBC 2.35-0ubuntu3.15) 2.35 # == 文档写的 glibc 2.35 地板
DISPLAY=:0 WAYLAND_DISPLAY=wayland-0
rustc 1.99.1 / cargo 1.99.1(rust-toolchain.toml 的 stable)
```
**构建**(与 CI 同一条命令,12 核 WSL,crates 走 rsproxy 镜像;镜像只影响下载速度,不影响产物):
```
cargo build --release --locked --bin unlucky-launcher-iced --bin ull
Finished `release` profile [optimized] target(s) in 9m 38s # BUILD_WALL=579.19s
-rwxr-xr-x 19571328 unlucky-launcher-iced
-rwxr-xr-x 7534848 ull
```
**打包**(CI 的同一份脚本 `packaging/make_artifacts.py`,`--slot linux-x64`):
```
arch: unlucky-launcher-iced -> x64 (ok for linux-x64)
smoke: ull --version -> 0.2.3 (ok) # 真跑,不是跳过(宿主 == 产物)
arch: ull -> x64 (ok for linux-x64)
artifact: unlucky-launcher_0.2.3_linux_x64.tar.gz 8.05 MiB sha256=c78e9f6cbab44e0fa76912f5d3608b911d89e2de2adc473339341d8ac3b60c13
artifact: ull_0.2.3_linux_x64 7.19 MiB sha256=42ff569152f1af89899b0925b4a7495bfcaeb66988889a0348722aa2d6ae2369
sums: SHA256SUMS.txt
```
归档解开就是用户拿到的东西(`unlucky-launcher` 0755;`ELF 64-bit LSB pie executable, x86-64, stripped`):
```
-rw-r--r-- LICENSE 11345 / NOTICE 4721 / README.md 30542
-rwxr-xr-x unlucky-launcher 19571328
$ ./unlucky-launcher --version
unlucky-launcher 0.2.3(unlucky-cli)
os: Ubuntu 22.04.5 LTS · 6.6.114.1-microsoft-standard-WSL2(x86_64)· 满足最低要求
```
**首帧(`--screenshot` 通道,真窗口在 WSLg 的真显示服务里开出来再渲)**,`exit=0`,关键日志行逐字:
```
[probe] adapters=0 hardware=false :: request=cpu -> ICED_BACKEND=tiny-skia
[font] platform=linux text="Noto Sans CJK SC" display="Noto Sans CJK SC" mono="DejaVu Sans Mono" | … source=env cjk=not-needed
[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)
[theme] source=default-light flavor=light follows_system=false
[window] requested size=1180x760 min=880x560 resizable=true decorations=false transparent=false scale_requested=system size_source=default title="Unlucky Launcher"
[boot] first frame in 485 ms(run() 开始 → 首次 view,含后端探测与字体扫描)
[shot] 1180x760 scale=1 -> …/wslg-first-frame.png
[shot] screen=shell theme=light renderer=tiny-skia route=Detail("survival")
[backend] final=tiny-skia result=started elapsed=1333ms evidence=probe-single-item
```
像素判定(`packaging/check_screenshot.py --json`,图 132637 字节):
```
{"width": 1180, "height": 760, "pixels": 896800, "unique_colors": 1858,
"top_color": "#f6faff", "top_color_share": 0.5564, "luma_mean": 241.18,
"luma_stddev": 24.06, "edge_ratio": 0.0372, "problems": []}
[ok] 截图非空白
```
**真的开了一个窗口**(交互模式,不加 `--screenshot`):进程 12 秒后仍存活,WSLg 侧出现
`msrdc` 窗口 **"Unlucky Launcher (cyrene-wsl)"**,`GetWindowRect` 读回 **1180×760**(正是它请求的尺寸),
从 Windows 侧对这块矩形截屏后**人眼可见**:自绘标题栏三键 + 左侧导航轨(Instances / Versions / Logs /
Settings / Assistant)+ "No instance selected" 空态 + 左下 "Not signed in"。这张图已入库:
[`docs/images/linux-x64-wslg-window.png`](../../docs/images/linux-x64-wslg-window.png)
(1180×760,43084 字节,`check_screenshot.py`: 唯一色 871、σ 14.4、强边缘 0.0137 ⇒ 非空白 —— 比进程内那张低,
因为桌面截屏是**已经合成到 Windows 桌面**的结果,抗锯齿被放大过一次;两张都远在阈值之上)。
交互模式的日志另有两行值得记:
```
[probe] adapters=1 [D3D12 (AMD Radeon(TM) Graphics) [Other]] hardware=false :: request=auto -> ICED_BACKEND=tiny-skia
[probe] 探测耗时 1527 ms(wgpu 实例 + 全后端适配器枚举)
[boot] first frame in 2273 ms(…)
[theme] source=system flavor=pending follows_system=true
[theme] 系统主题 = None
[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)
[corner] boot corner=policy=system source=square os=unknown excluded_reason=unknown-os radius=system pref=none readback=n/a window=none frame=? layered=?
```
读法(别过度解读):① WSLg 能枚举到一个 D3D12 适配器但 `hardware=false`,所以 auto 也落到软渲染
(`tiny-skia`)—— **GPU 通路在 Linux 上仍然没有证据**;② WSLg 没有"系统主题",所以 `follows_system=true`
而 `系统主题 = None`(真桌面上的系统主题跟随仍未验);③ **整窗透明与圆角在 Linux 上本来就是 no-op**:日志自己说
`hwnd=none`、`corner=os=unknown`(用户令:这两件事交给桌面合成器,我们不自绘)—— 所以它们不是"未验",而是
**在 Linux 上没有这条代码路径**。
**一处 WSLg 的坑**(写在这里免得下次重复踩):WSLg 的 X 侧只有 Weston 的代理窗口,
`import -window root` / `xwd -root` 一律 `X_GetImage … BadMatch`,**拿不到桌面级截屏**;
真窗口像素要么走进程内 `--screenshot`(CI 就是这么做的),要么从 Windows 侧对 `msrdc` 窗口矩形截屏(本节采用后者)。
## 3. 本轮实测(Xvfb,同一个 release 产物)
同一条命令在无头 X 里再跑一遍(Ubuntu 22.04 的 Xvfb,**release 产物**,判据与 CI 新增那步逐字一致):
```
xvfb-run -a --server-args="-screen 0 1280x1024x24" ./unlucky-launcher --software-renderer \
--screenshot …/xvfb-first-frame.png --data-dir …/data-xvfb
launcher exit=0
[theme] source=default-light flavor=light follows_system=false
[boot] first frame in 35 ms(run() 开始 → 首次 view,含后端探测与字体扫描)
[shot] 1180x760 scale=1 -> …/xvfb-first-frame.png
[backend] final=tiny-skia result=started elapsed=1147ms evidence=probe-single-item
{"width": 1180, "height": 760, "pixels": 896800, "unique_colors": 1858, "top_color": "#f6faff",
"top_color_share": 0.5564, "luma_mean": 241.18, "luma_stddev": 24.06, "edge_ratio": 0.0372, "problems": []}
[ok] 截图非空白
```
软渲染是确定性的:WSLg 与 Xvfb 两张图的统计量**逐个相同**(1858 / 24.06 / 0.0372),也正是这一点让
`check_screenshot.py` 的阈值(唯一色 ≥64、亮度 σ ≥3.0、强边缘 ≥0.001)有 **8~37 倍**余量
(唯一色 1858/64≈29×、强边缘 0.0372/0.001≈37×、**亮度 σ 只有 24.06/3.0≈8×** —— 与 `check_screenshot.py` 自己
写的「阈值取在实测下限的 1/5~1/8」一致,三者里最紧的是亮度 σ):CI 里这条门禁不会因为"某次抗锯齿抖了"而假红。
本机 Docker 复跑(`packaging/linux-smoke-docker.sh`,Debian 12 容器 + **debug** 档,缓存卷是热的):
```
$ bash packaging/linux-smoke-docker.sh . C:/Temp/ull-m184/smoke-out
rustc 1.99.1 (58cf8f9c3 2026-10-13) / cargo 1.99.1 / 12.15 # 12.15 = Debian 版本号
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 26s
[font] platform=linux text="DejaVu Sans" display="DejaVu Sans" … source=default cjk=not-needed
[theme] source=default-light flavor=light follows_system=false
[boot] first frame in 69 ms(run() 开始 → 首次 view,含后端探测与字体扫描)
[backend] final=tiny-skia result=started elapsed=1736ms evidence=probe-single-item
exit=0
/out/runtime-smoke.png: 1180x760 唯一色 1756 亮度σ 23.71 强边缘 0.03366 主色 #f6faff(55.7%)
[ok] 截图非空白
```
两条差异值得写下来(说明"为什么容器绿了还不够"):① 容器里没有中文字体,字体解析落到 `DejaVu Sans`,而 WSL 里是
`Noto Sans CJK SC` —— **字体选择随环境变**,观感类验收只能在真桌面上做;② 基础镜像是 Debian 12(glibc 2.36),
比发布地板 2.35 高一档,所以它能证明"这条运行时通路通",**不能**代替 `ubuntu-22.04` 上的证据(后者见 §2/§3 前面那段)。
单次启动的墙钟(`/usr/bin/time`,含 `xvfb-run` 起 X 的开销):**WALL=1.96s**,日志里 `launch elapsed=1188ms`。
## 4. CI 增量:2 个槽位 → 4 个槽位
默认矩阵从 **2 档变 4 档**(`windows-x64` / `windows-arm64` / `linux-x64` / `linux-arm64`),多出来的两档是**并行**作业
(`fail-fast: false`),所以:
| 项 | 变化 | 依据 |
|----|------|------|
| 并发作业数 | 2 → 4 | `release.yml` 的 `plan` 输出(本地跑那段 python:默认 4 档、`all_slots=true` 6 档) |
| 每档新增步骤 | `linux-x64` 多 **2 步**(xvfb 运行时冒烟 + 上传首帧图);`linux-arm64` 多 **0 步** | `release-build.yml`:11 步 → 13 步(本地 YAML 解析计数) |
| `linux-x64` 那 2 步的耗时 | apt 装 xvfb ≈ 30~50 s,跑一帧 ≈ 2 s,日志/像素断言 ≈ 1 s,上传 ≈ 3 s ⇒ **≈ 1~2 分钟** | 本地 Xvfb 实测单次启动 WALL=1.96s(`/usr/bin/time`) |
| 单档 release 构建时长(**估算**) | 冷缓存 ≈ 20~30 min;`Swatinem/rust-cache` 命中后 ≈ 8~15 min | 本机 12 核实测 **9m38s**;官方 `ubuntu-22.04` runner 是 4 vCPU,编译墙钟按 2.5~3× 估,再乘缓存命中折扣 |
| 总机时(**估算**) | 每 tag **+约 40 分钟**机时(2 档 × ≈20 min) | 同上 |
| 墙钟(**估算**) | 增量 ≈ `max(0, linux 档时长 − 现有最慢档)`,量级 **0~10 分钟**;发布侧多两个资产,秒级 | 四档并行,墙钟由最慢那档决定 |
| 配额风险 | `ubuntu-22.04-arm` 的可用性**本机无法核实**(无 `gh` CLI、无 Actions API 凭据) | 见 §5 最后一条:它不在发布名单里,起不来也不会挡住那两档发布 |
诚实边界:单档构建时长是**估算**(本机不是 runner,核数、缓存命中率、网络都不同)。下一次 tag 的真实数字应该回来
把这张表换成实测——**别让估算一直冒充实测**。
## 5. 仍然没验的部分(如实划出)
- **真实桌面发行版的观感**:拖动 / 缩放 / 字体度量与中文整形,一个都没在真桌面(GNOME/KDE 的可交互窗口)上
人眼验收过。本轮做的是「起窗 + 渲出一帧 + 断言像素与日志」,它证明「能跑出画面」,不证明「观感正确」。
(**整窗透明度与圆角不在这张清单里**:Linux 上它们没有代码路径 —— 见 §2 的三条读法,别把「不适用」写成「未验」。)
- **GPU 通路**:所有 Linux 证据都是软件渲染(`--software-renderer` / tiny-skia)。wgpu→Vulkan/GL 那条路在 Linux
上一次都没跑过。
- **Wayland 原生**:WSLg 同时提供 X11 与 Wayland,本轮只按 `DISPLAY` 走(见 §2 的实测环境),没单独验
`WAYLAND_DISPLAY` 那条路。
- **linux-arm64 运行时完全未验**:GUI 进程在 ARM64 上一次都没启动过。连 `make_artifacts.py` 那条 `ull --version`
冒烟也**只是机制上会真跑**(`mode: ci` + 宿主==产物),**截至本分支没有一次 arm64 成功出包的记录**,所以 arm64
侧一份运行时证据都没有 —— 别把「这条冒烟以后会跑」读成「它跑过了」。
- **`ubuntu-22.04-arm` runner 的可用性未在本机核实**:本机没有 `gh` CLI,也没有到 Actions API 的可用凭据,
所以「这档 runner 在这个仓库上真能起来」只能由下一次 CI 证明。风险是有界的:它**不在** `PUBLISH_SLOTS` 里,
起不来/红了不会挡住 `windows-x64` + `linux-x64` 的发布(只会让那一条 job 红,外加一条 `::warning::`)。
## 6. `linux-arm64` 的解锁条件(写死在这里,别凭感觉放行)
1. 在**真 ARM64 机器**(或原生 arm64 runner)上把 release 产物跑出一个非空白帧,`packaging/check_screenshot.py`
通过;
2. 同一份日志里有 `[theme] source=…` 与 `[backend] final=… result=started` 两行(证明主题来源与渲染后端都按预期);
3. 把上面两条的原始输出(时间、命令、图尺寸、日志行)贴进本文 §2 的位置;
4. 然后把 `linux-arm64` 加进 `release-publish.yml` 的 `PUBLISH_SLOTS`(一行),并同步 §9 与 `release-notes.md`。
构建绿了**不算**解锁,`ull --version` 跑得过也**不算** —— 那两条只证明 CLI 与编译器,用户拿到的那个 `.tar.gz` 里
装的是 GUI。(何况截至本分支 arm64 上连那条 CLI 冒烟都还没真跑过一次。)
+128
View File
@@ -0,0 +1,128 @@
# Cargo 构建档位与实测数据(M145 拆自 `packaging/README.md`)
本文承载原 `packaging/README.md` 的 §6、§7,**节号沿用原编号**(全仓 `packaging/README.md §N` 这类引用照旧有效,
对照表在 [`packaging/README.md`](../README.md) 的索引里)。内容一字未改、只搬家:本行以上是 M145 新增的导航说明,
以下逐行取自原文件。
## 6. Cargo 构建档位(release 体积档 / fast 速度档)
| 档位 | 用在哪 | opt-level | lto | codegen-units | panic |
|------|--------|-----------|-----|---------------|-------|
| `release` | 发布 / 验收产物 | `"z"`(workspace 自身 crate) | `fat` | 1 | `abort` |
| `release.package."*"` | 同上,**只作用于依赖** | `"z"` | 不可覆盖 | 16 | 继承 |
| `fast` | 日常迭代、本机跑测试 | 2 | `thin` | 16 | `unwind` |
- **`release` 是体积档**:`fat` + 单 CGU 换来实测 GUI −10.8% / CLI −18.7%(§7.1)。变慢的根源在 `codegen-units = 1`
(单 CGU → LTO 只一个线程可跑;`fat` 只管"跨 crate 全量参与",不等于单线程),375 个 crate 排队链接就是十几分钟的量。
- **`release.package."*"` 放宽的只有并行度,不是 opt-level**:实测把依赖 opt-level 抬到 3 反而**又慢 29.6%
又大 19~24%**(§7.1),所以保持 `"z"`、只把 `codegen-units` 放到 16。package 覆盖**只允许调优化强度**:
`panic` / `lto` / `rpath` 写进去 cargo 直接报错,`strip` 会改变整个二进制的运行时行为——都不许出现。
- **`fast` 是速度档,不是发布物**:`thin` LTO + 16 CGU 可并行,`opt-level = 2` 介于 `z` 与 3 之间。
`panic = "unwind"` 是给**编出来的二进制**留的(`release` 是 `abort`,`inherits` 会照抄);`cargo test`
**不**依赖它——cargo 对 test 目标自己强制 unwind(实测 release/abort 档下 `cargo test --profile release`
照跑、23 个测试全过,test 单元的 rustc 参数里没有 `-Cpanic=`)。要看这行是否生效,比 `cargo build -v`
的 rustc 参数:release 有 `panic=abort`、fast 没有。它继承 `strip="symbols"`,所以能 unwind 但**没有符号
回溯**——要栈回溯就给 fast 档加 `strip = false`,代价是体积再涨。
- **`fast` 在机械盘机器上冷编译并无收益**(比 release 还慢、体积 2.3 倍,§7.1),价值在 SSD 机器与增量重编。
- **`[profile.fast.package."*"]` 是承重墙**:`inherits` 会把 release 的 package 覆盖一起带过来(实测:删掉它,fast 档外部依赖静默退回 `"z"`),必须存在。
- 本地开发迭代走 `cargo build`(dev profile)不受影响,但 **`ci.yml` 每次 push/PR 都跑
`cargo build --workspace --release`**,会实打实吃这份 fat LTO 的链接时间——CI 慢的一部分是 release 档构建。
## 7. 实测(本机,不是估算)
构建环境:rustc 1.99.1 stable / `x86_64-pc-windows-gnu`(MinGW 链接器)。§7.2/§7.3 的早数字是另有 worker 并发做全量 debug 构建时测的(偏悲观);§7.1 的三行 M96 数据是空载冷编译。
**本节以下所有输出里的 `0.1.0` 都是当时那次运行的真实输出(workspace 版本那时是 `0.1.0`),属实测记录,不随后续版本号改动而回填**;当前版本见 `Cargo.toml` 的 `[workspace.package] version`。§2 的校验机制不依赖任何写死的数字,改版本号不会让它失效。产物名同理且更容易被误读成现状:§7.3 / §7.5 里的 `unlucky-launcher-0.1.0-windows-*.exe`(**连字符**)是 2026-11-01 那几次实测的原文,D49(2026-11-05)才改成下划线形态——同一槽位现在叫 `unlucky-launcher_0.2.3_windows_x64.exe`(规则见 §1);拿旧名去 Release 页或磁盘上找文件必然找不到,那不代表发布挂了。
### 7.1 体积与冷构建耗时(实测字节数,不是估算)
| 档位 | `unlucky-launcher.exe` | `unlucky-launcher-iced.exe` | `ull.exe` | 冷构建耗时 |
|------|-----------------------|----------------------------|-----------|-----------|
| `release`,`thin`(M46 前) | 16,366,080 B = 15.61 MiB | 未测(当时无 iced 版) | 7,572,992 B = 7.22 MiB | 11m52s |
| `release`,`fat`,依赖全 `"z"`(M46,改前) | **14,595,584 B = 13.92 MiB** | 未测 | **6,157,824 B = 5.87 MiB** | 9m27s(增量) |
| `release`,`fat` + 依赖 opt-3 / cu16(M96 试过的那版,**已否**) | 20,407,296 B = 19.46 MiB | 18,371,584 B = 17.52 MiB | 7,743,488 B = 7.38 MiB | 22m45s(冷) |
| `release`,`fat` + 依赖 `"z"` / cu16(**M96 起,发布档**) | **16,479,232 B = 15.72 MiB** | **13,910,528 B = 13.27 MiB** | **6,256,128 B = 5.97 MiB** | **16m01s(冷)** |
| `fast`,`thin` + cu16(M96 起,日常档) | 38,182,400 B = 36.41 MiB | 33,570,304 B = 32.02 MiB | 15,967,744 B = 15.23 MiB | 24m27s(冷) |
测量条件(M96 三行):12 核 / `x86_64-pc-windows-gnu` / `-j 4`,源码机械盘、`CARGO_TARGET_DIR` 在 SSD,均为**删空 target 后的
冷编译**、同机同晚无并发负载;M46 两行是更早提交上的数字(依赖已热 / 另有并发),**跨行比耗时没意义**。
① **依赖 opt-level 从 3 退回 `"z"` 又快 29.6% 又小 19~24%**(两臂同为 cu16,唯一自变量是 opt-level)——抬 opt-level 是拿编译时间和体积换运行期;
`cu1 → cu16` 的收益本轮**没有同条件对照**,未归因。② `fast` **冷编译比 `release` 还慢**(24m27s vs 16m01s)、体积 2.3 倍,瓶颈在两个大 UI
crate + 链接 + 机械盘 IO——定位是 **SSD 机器与增量重编**,不是冷编译加速。体积归因:GUI 相对旧基线 +12.9%(13.92 → 15.72 MiB)里
「代码增长」与「依赖 cu16」**未分离**;`ull` 只 +1.7%,在 +1~3% 口径内。体积构成(**M118 前的 Slint 版,历史数**):Slint 运行时(双渲染器 + winit/accesskit/托盘)+ rustls/reqwest + rig/rmcp/axum 为主,`ull` 更小是 D18(CLI 零 UI 依赖)的结果。
### 7.2 冒烟(真跑,2026-10-31 夜间 + 11-01 早,本机 Win11 26100)
| 命令 | 实测结果 |
|------|---------|
| `ull.exe --version` | 输出 `ull 0.1.0(unlucky-cli)` + `os: Windows 11 · 10.0.26100(x86_64)· 满足最低要求`,exit 0 —— 版本与 workspace `0.1.0` 一致 |
| `unlucky-launcher.exe --software-renderer --screenshot … --data-dir …`(Slint 版,历史实测) | exit 0,产出 1180×760 PNG(3,588,298 B);看图确认 Metro 实例页(侧栏/搜索/空态/AI 助手面板)正常渲染 → **软渲染路径真的能出图**,不依赖 GL |
| `unlucky-launcher.exe --version` | **无输出**,8 秒后被 timeout 杀掉(exit 124)——GUI 当时不解析 `--version`,会直接开窗口。M82 后已修(走 CLI 分发,见 §10-1)|
### 7.3 打包验证(本机 Windows 槽位真跑;产物名是 D49 改名前的形态)
```
smoke: ull.exe --version -> 0.1.0 (ok)
artifact: unlucky-launcher-0.1.0-windows-x64.exe 13.92 MiB sha256=d4954c62…c3570
sums: SHA256SUMS.txt
$ sha256sum -c SHA256SUMS.txt
unlucky-launcher-0.1.0-windows-x64.exe: OK
```
版本守卫也实测过:`--version v9.9.9` 时脚本以 `tag/version mismatch: requested 9.9.9, Cargo.toml says 0.1.0` 失败退出(exit 1)。
UNIX 归档的可复现性单独取证(本机 Windows 槽位只走裸 exe 分支,tar 路径要另测):
reviewer-packaging 在 M46 review 里连续调 `stage_archive()` 两次 → **两次 sha256 完全一致**
`a99d6e9e727cd7e390434c566a15978451f340858fa89836e2f63790bbc392f8`;成员 `mtime=0`、
`uid=gid=0`、`uname/gname` 为空、`mode` 0755(二进制)/ 0644(文本),gzip header 的 mtime 字段是
`00000000`,归档内单顶层目录且含 `LICENSE`/`NOTICE`/`README.md`。
即"同源构建 → 同 sha256"在 tar 路径上成立,不随打包时刻漂移(见 §1 的约定细节)。
### 7.4 运行时依赖证据(PE 导入表 / ELF `DT_NEEDED`)
`objdump -p target/release/unlucky-launcher.exe` 共 42 条 DLL 导入记录、33 个不同名字(大小写各记一次),全部是 Windows 自带:
`KERNEL32/USER32/GDI32/ADVAPI32/SHELL32/SHLWAPI/ole32/oleaut32/combase/comctl32/dwmapi/dwrite/imm32/
uxtheme/uiautomationcore/opengl32/ws2_32/bcrypt/bcryptprimitives/ntdll` + 一组 `api-ms-win-crt-*`(UCRT API set)。
**没有** `VCRUNTIME140.dll` / `MSVCP140.dll` 这类 MSVC 可再发行包依赖,也没有 `libwinpthread-1.dll` /
`libgcc_s_*.dll` 之类 MinGW 运行时 —— 即"拷走一个 exe 就能跑"(Win10+ 自带 UCRT)。
CI 的 MSVC `+crt-static` 产物同理(静态 CRT);`release.yml` 里用 `dumpbin -dependents` 把这条变成硬门禁,
两侧都 fail、没有降级分支:**出现 VCRUNTIME/MSVCP/ucrtbase 就 fail**,**找不到 dumpbin 同样 fail**
(`windows-latest` 与 `windows-11-arm` 两个镜像都自带 VS 工具链,基本找得到;跳过等于让门禁在最需要它时失效)。
总之不允许"悄悄要用户装可再发行包"。
注意:本机只装了 GNU 工具链,MSVC 那个槽位的产物本身**没有在本机验证过**,由 CI 首跑验证。
Linux 侧同一件事的对照(M82 实测:`readelf -d` + `ldd` + `nm -D`,容器内 Debian 12 glibc 2.36;被测的是 **Slint 版 GUI,历史实测**):Slint
GUI 的 ELF `DT_NEEDED` 只有 `libfontconfig.so.1` / `libgcc_s.so.1` / `libm.so.6` / `libc.so.6` /
`ld-linux-x86-64.so.2`;`libX11` / `libxcb` / `libxkbcommon` / `libwayland-client` / `libGL` / `libEGL`
**都不在**导入表里,而是以字符串形式由 `dlopen` 在开窗那一刻加载(`nm -D` 可见 `U dlopen@GLIBC_2.34` /
`dlsym` / `dlclose`)。所以 §4 那张「开窗要的库」不是启动前提,缺了也不影响进程起来跑 CLI;
`ull` 的 `DT_NEEDED` 连 fontconfig 都没有,只剩 glibc 那一组。
**补一次真跑(M82 复审在 `debian:bookworm-slim` 上做的)**:镜像里 X11 / wayland / GL / EGL / xkbcommon /
libxcb 条目数 = 0,只把 fontconfig 那 7 个 `.so` 放进 `LD_LIBRARY_PATH`,跑**改动后**的二进制 → `--version`
与 `--help` exit 0、`instances list --json` 合法 JSON exit 0、`lst` exit 1 且带提示;`readelf -d` 复核仍是
恰好 5 条 `DT_NEEDED`(无 `libstdc++`、无任何图形库),`ull` 4 条 —— 即「能跑完子命令」是实测,不是推论。
### 7.5 arm64 槽位的本机验证(脚本层取证,不假装跑得动 arm64 二进制)
本机没有 ARM 机器,也不具备 MSVC ARM64 链接库组合,所以只做**能真实证明的东西**(`python` 直接加载
`packaging/make_artifacts.py` 调函数 + 走 CLI 全流程,证据见下):
| 取证项 | 结果 |
|--------|------|
| 宿主判定注入 | `win32/AMD64`→`windows-x64`、`win32/ARM64`→`windows-arm64`、`linux/x86_64`→`linux-x64`、`linux/aarch64`→`linux-arm64`、`linux/arm64`→`linux-arm64`、`darwin/arm64`→`macos-arm64`、`darwin/x86_64`→`macos-x64`、`freebsd/riscv64`→exit 1 |
| 文件头架构识别 | 真 `unlucky-launcher.exe`→`x64`;合成 PE `0xAA64`→`arm64` / `0x8664`→`x64`;ELF `0xB7`→`arm64` / `0x3E`→`x64`;Mach-O `0x0100000C`→`arm64` / `0x01000007`→`x64`;垃圾字节→`None`(拒绝出包) |
| `windows-arm64` stub 全流程 | 把真 exe 的 PE 机器码改成 ARM64 当 stub:`arch: … -> arm64 (ok for windows-arm64)`、`smoke: skipped (host is windows-x64, artifact is windows-arm64)`、产物 `unlucky-launcher-0.1.0-windows-arm64.exe`(当次实测的连字符名)14.01 MiB,`SHA256SUMS.txt` 行名一致 |
| 架构守卫真拦 | x64 二进制冒充 `--slot windows-arm64` → exit 1:`is a x64 binary but slot windows-arm64 says arm64` |
| `linux-arm64` stub 归档 | 成员 `unlucky-launcher-0.1.0-linux-arm64/{LICENSE,NOTICE,README.md,unlucky-launcher}`(旧连字符名,当次实测),权限 0644/0755、`uid=gid=0`、mtime 0、gzip 头 mtime 字段 `00 00 00 00`;连调两次 sha256 完全一致(`f949d0f5…`)→ 可复现规则与 x64 同源 |
| `windows-x64` 原生回归 | `arch: … -> x64`、`smoke: ull.exe --version -> 0.1.0 (ok)`、产物 14.01 MiB、`sha256sum -c SHA256SUMS.txt` OK(这一次用的是当前工作树的二进制,与 §7.3 那次不是同一次构建,sha256 自然不同) |
**没在本地验证、只能由 CI 补的**:`windows-arm64` 在 `windows-11-arm` 上的真实链接与 ARM64 导入表、
`linux-arm64` 在原生 arm64 runner 上的真跑(本机既没有 ARM 机器,也连 `raw.githubusercontent.com` 都解析不了)。
**M164 的更正**:M141/M142 那个编译前体检不但没帮上忙,还是 arm64 五轮全红的唯一原因(`find` 猜 SDK 的
`um/arm64/kernel32.lib`,猜缺就 `exit 1`,而 `cargo build` 永远 skipped);M164 把它降级为只取证(打印 vswhere 组件、
目录 `.lib` 计数、`link.exe` 候选),红绿交回编译器。闭环标准不是「两槽位都进 SHA256SUMS」(M142 起只有 x64 进),
而是 arm64 的 `cargo build` 真跑绿 + 把键加回 `PUBLISH_SLOTS`(守卫会自动跟着扩,并在产物有了却没声明时报 `::warning::`)。
本机能补的只有脚本层证据:`python packaging/check_publish_slots.py --self-test`(11 条注入回归)与
`cargo check --target aarch64-pc-windows-msvc`(依赖图对该 target 的类型检查,**不链接、不产出 PE**)。
+86
View File
@@ -0,0 +1,86 @@
# 每平台运行时依赖与软渲染兜底(M145 拆自 `packaging/README.md`)
本文承载原 `packaging/README.md` 的 §4、§5,**节号沿用原编号**(全仓 `packaging/README.md §N` 这类引用照旧有效,
对照表在 [`packaging/README.md`](../README.md) 的索引里)。内容一字未改、只搬家:本行以上是 M145 新增的导航说明,
以下逐行取自原文件。
## 4. 每平台运行时依赖
### Windows x64(Win10/11)
| 依赖 | 来源 | 说明 |
|------|------|------|
| `KERNEL32` / `USER32` / `GDI32` / `ADVAPI32` / `SHELL32` / `ole32` / `WS2_32` … | 系统 | Win10+ 一律自带 |
| `ucrtbase` / `api-ms-win-crt-*`(UCRT) | 系统 | Win10+ 内置,**不需要** VC++ 可再发行包 |
| MSVC 运行时(`VCRUNTIME140` / `MSVCP140`) | **无** | `+crt-static` 静态链接,故不需要可再发行包 |
| OpenGL(`opengl32`) | 系统 | 只在 GL 渲染路径用;缺失走软渲染 |
本机(GNU 工具链)实测导入表证据见 §7。
### Windows ARM64(Win11 on ARM)
**本版不发布**(CI 照构建取证,红了不挡 x64 发布;等它绿了把键加回 `PUBLISH_SLOTS` 即可)。产物形态与门禁跟 x64
一样(裸 exe + 静态 CRT + dumpbin 导入表硬门禁),差别只在**怎么编**:**M140 起在原生 `windows-11-arm` 上编**
(GitHub 文档 2026 的 Standard runners 表明列该标签:4C/16G、公开仓库免费),产物落在 `target/release`。原路线
是在 `windows-latest` 上交叉到 `aarch64-pc-windows-msvc`,但**CI 上从未成功过一次**(v0.2.1 首次真跑即红在
「ARM64 工具链体检」)。M142 已确认那几轮「只剩一行 exit code 1」是**诊断脚本自己先死**(`set -uo pipefail` 关不掉
runner 注入的 `-e`,`find` 扫不存在的根被 pipefail 判非零就退出),已修;ARM64 的静态库在这台 runner 上到底缺不缺
要看下一次运行的新注解(固定 4 行实况清单,见 §3)。原生的附赠好处:宿主=产物架构,`ull --version` 冒烟**真跑**。
运行时依赖与 x64 同表(UCRT 是 Win11 ARM 的系统组件,`+crt-static` 静态链 CRT,不需要可再发行包)。
游戏侧如实说:**启动器原生运行**;Temurin 没有 windows-aarch64 产物,Microsoft OpenJDK 有该
发行版(core 的 `microsoft` 源按 `<os>-<arch>` 拼名,命名已覆盖),但 Minecraft 本体没有
Windows ARM64 原生库(Mojang/LWJGL 只发 x64/x86),实际游玩仍走系统 x64 模拟层 + x64 JDK。
所以本槽位只承诺「启动器产物」支持,不替 Java/游戏管理做架构级承诺。
### Linux x64
| 依赖 | 说明 |
|------|------|
| glibc ≥ 2.35 | 由 `ubuntu-22.04` 构建决定(该槽位默认不编,`all_slots=true` 取证跑才有产物);更老的发行版请自行重建或等 musl 版 |
| `libxkbcommon.so.0` | winit 键盘处理 |
| `libfontconfig.so.1` + `libfreetype.so.6` | 文本渲染 |
| `libGL.so.1` / `libEGL.so.1` | Slint 版:femtovg 的 GL 渲染;iced 版:wgpu 的 GL/EGL 后端(**推断,未实测**)。两者缺失都退到 CPU 软渲染(tiny-skia) |
| `libxcb.so.1` / `libX11.so.6` | X11 后端(Wayland 会话下也需 XWayland 相关库) |
| `libwayland-client.so.0` | Wayland 后端 |
Debian/Ubuntu 一行:`sudo apt install libxkbcommon0 fontconfig libgl1 libegl1 libxcb1 libx11-6 libwayland-client0`。
运行期**不需要** `-dev` 包、不需要 Java(Java 是启动游戏时才由启动器自己管理的东西)。
**M118(D26)口径**:除上表里标注「推断,未实测」的那一行外,这张表与下面这条结论都是 **Slint 版 GUI** 的实测记录(M82:`readelf -d` + `ldd` + `nm -D`);iced 版(`crates/app-iced`)的 `DT_NEEDED` / `dlopen` 集合**尚未重测**,请按「同一族库、细节待验」读,别当成 iced 版的实测承诺。
仍然成立的部分:上表是**开窗**要用的库,不是进程启动门槛——实测(§7.4,Slint 版)除 `libfontconfig.so.1` 外它们**都不在** ELF 的 `DT_NEEDED` 里,由 `dlopen` 在开窗那一刻才加载,所以没有 X11 / Wayland / GL 的机器上 `unlucky-launcher instances list` 照样跑得起来,`ull` 连 fontconfig 都不需要。
arm64 侧同理(同一份依赖树;历史口径里的「winit + femtovg」中 femtovg 只属于 Slint 版)。
### Linux arm64(aarch64)
依赖与 x64 完全同一套(`libxkbcommon` / `fontconfig` / `libGL` / `libxcb` / `libwayland-client`),
**glibc 地板也相同**:arm64 槽位跑在原生 `ubuntu-22.04-arm` runner 上(默认矩阵不编它,只有 `all_slots=true` 取证跑才出产物),地板 = **2.35**,与 x64
用的 `ubuntu-22.04` 一致。选 22.04 而不是 24.04 就是为了这个地板——24.04 的 glibc 更高,会把
Debian 12(2.36)、Raspberry Pi OS Bookworm(2.36)这些实际在跑的 ARM 发行版挡在门外。
要抬地板(例如将来 ARM 生态整体上移)换 `release.yml` 里那个槽位的 `os` 即可,脚本与产物名都不用动;
`ubuntu-24.04-arm` / `ubuntu-26.04-arm` 都是同一家族的标准公开仓库标签(4C/16GB、免费不限量)。
### macOS 12+
| 依赖 | 说明 |
|------|------|
| 系统框架(Cocoa / Metal / IOKit / Security …) | 由操作系统提供,无第三方动态库 |
| `libSystem` | 系统 |
macOS 产物是**裸二进制**而非 `.app` bundle:符合单文件承诺,但没有 Dock 图标、Finder 双击不生效,
从终端启动(README 里给了 `xattr -d com.apple.quarantine` 的 Gatekeeper 解法)。
签名/公证(需 Apple Developer 账号)与 `.app` 打包是后续工作,不做假承诺。
## 5. 软渲染兜底(无 GPU / 老驱动 / 远程桌面)
**现役入口(iced 版)**:`unlucky-launcher-iced --software-renderer`(等价 `--renderer=cpu`,即 `ICED_BACKEND=tiny-skia`)
走 tiny-skia 光栅化,这是现役构建唯一的软渲染入口——CI 的 runtime-smoke 与 `packaging/linux-smoke-docker.sh` 都这么跑(§11)。
**历史资产(`v0.1.0` 的 Slint 版)**:那批已发布二进制仍认 `unlucky-launcher --software-renderer`
(解析在 `crates/app/src/main.rs` 的 `apply_renderer_flag()`,只认完全相等的那个串)。
这条 Slint 路径(默认 `renderer-femtovg`/GL)已不在默认构建与发布矩阵内——`crates/app` 被 workspace `exclude`,
源码仅作参考、不编译——所以它**对现役 `unlucky-launcher-iced` 无效**,别照抄到 iced 版上。
- 软渲染是 CPU 光栅化,无 GPU 也能出图;代价是动画与大窗口重绘更慢(Metro 纯色界面影响有限)。
- 截图通道 `--screenshot <png>`:历史实现 `crates/app/src/screenshot.rs`,现役 `crates/app-iced/src/shot/`;
软渲染下照样出图,是 CI 与远程机唯一可靠的"真出图"证据(发布冒烟见 §7,本机实跑 `--software-renderer --screenshot`)。
+190
View File
@@ -0,0 +1,190 @@
# 签名就绪度操作手册(M255)
本文件是**人类照做**的手册:从「信任表是空的」走到「发出去的 Release 用客户端判据能验过」。
背景/威胁模型/轮换纪律在 [`signing.md`](signing.md);本文件只讲**步骤、命令、坑**。
> ⚠ 本文件**不含**任何真实私钥、种子或公钥。所有示例都是 `<尖括号占位符>`。
> `--generate-key` 的输出是**敏感物**:只在你要贴进 Secret 的那一次运行里存在,别存文件、别贴 issue。
## 0. 现状读一遍(2026-11 的 M254 审计结论)
`crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是**空的**。空表 =
`KeyringEmpty` 一律拒装 ⇒ **今天任何正式二进制都验不过任何清单**(fail-closed 成立,但用户端表现为
「更新永远失败」)。已存在的部分(都能用):
| 环节 | 在哪 | 今天的状态 |
|---|---|---|
| 生成钥 | `sign_manifest.py --generate-key` | 有;打印 Secret JSON + `PinnedKey` 那一行 |
| 钥 ↔ 信任表对账 | `sign_manifest.py --check-pinned-keyring` | 有;**今天必然红**(表空 ⇒ 「信任表是空的」) |
| 签名 | `sign_manifest.py --sign dist/manifest.json` → `.sig` | 有;**今天必然红**(`ULL_MANIFEST_SIGNING_KEY` 没有,前面先红) |
| 回验 | `sign_manifest.py --verify-pinned dist/manifest.json` | 有;**今天必然红**(表空 ⇒ 「无从回验」) |
| 发布接线 | `release-publish.yml` 的「签更新清单」步 | 有;上面四条任一非零 ⇒ 不出 Release(**这是对的**) |
| 资产上传 | `release-publish.yml` 的 `assets=(… manifest.json manifest.json.sig …)` | 有 |
| **操作步骤与可见性** | 本文件 + `packaging/check_signing_ready.py` + `signing-status.yml` | **本次补上的就是这两样** |
一句话:**工具链不缺,缺的是有人把钥钉进表里。** 那件事只有人类能做(塔不会自己生成生产私钥)。
## 1. 先看读数(不用碰任何钥)
```bash
python3 packaging/check_signing_ready.py # 三行读数 + 退出码
python3 packaging/check_signing_ready.py --dist dist # 顺手看一眼本地留档
```
三行分别是 ① `PRODUCTION` 表空不空 ② 环境里有没有签名钥(并顺手对账「这把钥在不在表里」)
③ 最近一次发布的 `.sig`/清单痕迹(取不到就写「未知」——本地没留档 ≠ 上次没签)。
退出码:`0` 就绪 / `1` 未就绪 / `2` 用法错(参数写错、`trust.rs` 读不到、标记丢了)。
**未就绪时会打印一行修复命令**,直接照抄即可。CI 里同一条读数在 `Signing status(播报)` workflow 的
job summary 里(见 §6 —— 它是播报,不是门禁)。
## 2. 人类要亲手做的 5 步(下单清单)
下面每一行都只有命令与占位符,**没有任何密钥值**。做完一步就把该步的验证跑一遍。
> 🔴 **顺序硬要求(M255 r1 P2-1):先合并「播报 workflow 的 ref 门」那笔改动,再录 Secret。**
> 播报 workflow(`.github/workflows/signing-status.yml`)会把 `ULL_MANIFEST_SIGNING_KEY` 注入到
> 就绪度那一步的环境里;那个注入**只在 `refs/heads/main` 上发生**,而且该 workflow **没有**
> `pull_request` 触发。这两条是安全边界:仓库内分支的 PR 与任意分支的 `workflow_dispatch` 都拿得到
> 仓库 Secret,而 CI 会在**那个 PR 自己的代码**上执行 checker —— 种子一旦进过那种运行,等于交给
> 任何能推分支的人(这把种子能给**全量已安装客户端**签可信清单,即用户端 RCE)。
> 所以:**ref 门那笔没合进去之前不要录 Secret**;录之前先确认线上版本里有
> `github.ref == 'refs/heads/main'` 这个条件式(§6 也写了这条)。
```bash
# ① 生成钥(⚠ 输出是敏感物;不要跑在 CI 里,不要重定向到仓库里的文件)
python3 packaging/sign_manifest.py --generate-key --key-id <key-id,例 ull-release-2026a>
# ② 把打印出来的那一行 JSON({"key_id": …, "seed_hex": …})贴进仓库 Secret(⚠ 先读上面的顺序硬要求):
# Settings → Secrets and variables → Actions → New repository secret
# 名字必须**逐字**是:ULL_MANIFEST_SIGNING_KEY
# (等价 CLI:gh secret set ULL_MANIFEST_SIGNING_KEY,然后按提示粘贴,别用 -b 把值留在 shell 历史里)
# ③ 把打印出来的那一行 PinnedKey { … } 贴进 trust.rs 的 PINNED-KEYS 标记之间(标记本身别动)
grep -n 'PINNED-KEYS' crates/core/src/update/trust.rs
# 贴完提交这次改动 —— 公钥进 git 是对的:它是信任锚,本来就该公开可审计
# ④ 对账(需要一个有钥的环境:本机就先 export,别写进任何文件)
python3 packaging/sign_manifest.py --check-pinned-keyring # 期望:ok: 签名钥 <key-id> 已被钉住
# ⑤ 发一次带 .sig 的 Release,然后用**客户端的判据**回验(这一步不需要私钥)
python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # 期望:ok: 用钉住的公钥能验过
```
`--verify-pinned` 才是证据:`--sign` 说「签好了」不算 —— 客户端拿的是 `trust.rs` 里那把公钥。
发布后建议再用**下载下来的资产**验一次(§5),因为「CI 上能验过」不等于「上传的那份就是被签的那份」。
### 每一步的判据(怎么知道这步真的成了)
| 步 | 成功长什么样 | 失败长什么样 |
|---|---|---|
| ① | 打印 ① 私钥 JSON、② 一行 `PinnedKey`、③ 公钥十六进制;脚本自带「自己签自己验」自检 | `error: 自己签自己验不过` ⇒ 不要用这把钥,环境有问题 |
| ② | `gh secret list` 里能看到 `ULL_MANIFEST_SIGNING_KEY` | 列表里没有 / 名字拼错(大小写、下划线) |
| ③ | `check_signing_ready.py` 的 ① 从「未就绪」翻成「就绪」 | ① 仍说 0 条 ⇒ 贴到了标记**外面**(读数里会点名「另有 N 条在标记之外(key_id:…)」) |
| ④ | `ok: 签名钥 … 已被钉住` | `签名钥(…)不在信任表里` ⇒ ②③ 不是同一把钥 |
| ⑤ | `ok: manifest.json.sig 用钉住的公钥 … 能验过(客户端会接受)` | `拿 trust.rs 里钉住的公钥回验不过 —— 这份 Release 的用户会全部拒装。别发版` |
## 3. 一次发布里签名是怎么被执行的(不用额外操作)
`release-publish.yml` 的「签更新清单」步按顺序跑四条,任一非零 ⇒ 本步红 ⇒ 不出 Release:
```bash
sign_manifest.py --self-test # RFC 8032 向量:脚本自己没坏
sign_manifest.py --check-pinned-keyring # Secret 里的钥必须已在 trust.rs 钉住
sign_manifest.py --sign dist/manifest.json # 产出 dist/manifest.json.sig
sign_manifest.py --verify-pinned dist/manifest.json # 用钉住的公钥回验产出
```
这一步已经把「未配置 Secret」「钥没钉」「钥不匹配」都变成**发版前的红**,所以「发了但没签名」这条
不会发生 —— 它只会在发布 job 上红,不会静默发出去。
## 4. 已知坑(按踩到的概率排序)
1. **Secret 名字拼错**(`ULL_MANIFEST_...` vs `ULL_MANIFEST_KEY`):可复用工作流只拿得到自己**声明过**的
secret,名字不对就是空串 ⇒ 签名步红,而错误文案会反过来指控「Secret 没配」。`release-publish.yml`
已经声明了这一个(`required: false`,缺省走 fail-closed),照抄名字即可。
2. **只配 Secret 没钉表**:`--check-pinned-keyring` 报「信任表是空的(fail-closed)」,并**直接打印**
该贴的那一行 `PinnedKey`(只含公钥,安全)。照贴即可。
3. **只钉表没配 Secret**:`--check-pinned-keyring` 报「没有签名密钥」。这不影响已有用户(表非空 ⇒
他们能验签),但发不了新版。
4. **Secret 与 trust.rs 是两把不同的钥**:最常见于「换过钥但只更新了一边」。判据是**公钥字节**,
`key_id` 只是标签;`--check-pinned-keyring` 会把两边的公钥都打出来对账。
5. **把条目贴到标记外面**(`];` 之后、或贴着别的 `const`):等于没钉。`check_signing_ready.py` 会**点名**
标记之外的 `key_id`;注意本仓 `TEST_ONLY` 那条**本来就在标记之外**(那是它的正确位置),所以读数是
「另有 N 条在标记之外(key_id:…)」而不是「贴错了」—— 对着 `key_id` 自己认。
6. **`set -x` / `echo $ULL_MANIFEST_SIGNING_KEY`**:一次就够把私钥泄进日志。工作流里那一步显式 `set +x`
且只回显**公钥**;本机调试同理,别开 trace。
7. **`--generate-key` 的输出落进文件/issue/聊天**:当成泄露处理,走 `signing.md` §5 轮换(旧钥签新钥)。
8. **以为「非 main 分支的读数」就是仓库状态**:播报 workflow **只在 `refs/heads/main` 上注入** Secret
(也没有 `pull_request` 触发 —— 见 §2 的顺序硬要求与 §6)。非 main ref 的运行里 ② 显示
「**未判定** —— 非 main ref(按设计不注入 Secret)」,这是**策略**,不是「没配」。以 `main` 上的读数为准。
9. **`.sig` 没上传到 Release**(手改过 `assets=(…)` 那一行):客户端取 `<产物根>/manifest.json.sig`
会 404 ⇒ 所有人验签失败。上传后请在资产列表里**亲眼看到** `manifest.json` 与 `manifest.json.sig`
两个名字(§5 的 `gh release view` 那条命令就是干这个的)。
10. **删掉旧钥条目太早**:用户机器上装着旧二进制,它只认旧钥;删条目的那个版本必须**只**由新钥签,
并且至少一个发布周期的双签名之后(`signing.md` §5)。
11. **「昨天验过」不等于「今天验得过」**:`PRODUCTION` 是**编译期**常量,改 `trust.rs` 只影响**下一次构建**。
已在用户盘上的旧二进制永远用旧表 —— 这是设计,不是 bug。
12. **给播报 workflow 加回 `pull_request` 触发、或去掉注入行的 ref 门**:等于把生产签名种子交给任意推
分支的人的代码(自更新链 = 用户端 RCE)。这两处各有接线用例钉着(去掉任一处都会红),
别为了「PR 上也能看读数」去动它 —— 看读数请对 `main` 跑,或读 §1 在本地跑。
## 5. 发布后回验(发布侧,不需要私钥)
```bash
# 资产里必须两个名字都在(漏一个等于全员验签失败)
gh release view <tag> --json assets --jq '.assets[].name' | grep -E 'manifest\.json(\.sig)?$'
# 拿下载回来的真实资产复现客户端判据:manifest.json 与 .sig 必须成对、且是同一份字节
mkdir -p /tmp/verify && gh release download <tag> --pattern 'manifest.json*' --dir /tmp/verify
python3 packaging/sign_manifest.py --verify-pinned /tmp/verify/manifest.json
```
退出码 0 = 「客户端会接受这份清单」。非零时**不要**手动 `edit` Release 绕过 —— 先看脚本 stderr
(它会说清楚是表空、无 `.sig`、还是签名对不上)。
## 6. CI 里那条读数:`Signing status(播报)`
`.github/workflows/signing-status.yml` 在 `push: branches: [main]` 与**手动 dispatch** 上跑,把 §1 的三行
搬进 job summary。**它不是门禁**:
* 它**不应被设为 required check**(分支保护里不要勾它),也不阻断任何其它 job;
* 它就绪度那一步**恒 `exit 0`**:未就绪用 `::warning::` 播报,不用 error 级注解(那会让人以为构建挂了);
* **生产签名种子只在 `refs/heads/main` 上注入**(`github.ref == 'refs/heads/main' && secrets.… || ''`),
而且**没有 `pull_request` 触发**。理由是安全:仓库内分支的 PR / 任意分支的 dispatch 都拿得到仓库 Secret,
而 CI 跑的是那个 ref 自己的代码 —— 种子进过那种运行就等于交给任何能推分支的人。
非 main ref 的运行里 ② 显示「**未判定** —— 非 main ref(按设计不注入)」,**不写「没配」**;
* 它**刻意不声明 `workflow_call:`**,所以别的 workflow 没法 `uses:` 它;接线自检还会扫全部 workflow,
任何 `uses: …/signing-status.yml` 出现即红(防的是「顺手接进 dev 线把播报变成门禁」);
* 它**绿**只说明脚本跑完了;结论只看 summary 里的三行字。
* 同一 workflow 里另有两步跑自测(`packaging/tests/signing_ready_selftest.py` +
`packaging/tests/signing_ready_wiring_selftest.py`):那两步**会红**,红 = **尺子坏了**(三行读数/退出码/
变异重放/接线与 ref 门之一没过),与「签名未就绪」是两件不同的事,措辞在 summary 里分开写。
为什么不做成门禁:今天已经有四次「守卫假绿」的账(见 `packaging/README.md` §12 里那几条先例)。
把一条**按定义今天必然失败**的检查做成 required,只会有两种结局 —— 要么所有人学会忽略红叉,
要么有人把它改成永远绿。真正的红留在 `release-publish.yml` 的签名步:那里今天红是**对的**,
因为签不出来的产物本来就不能发。
## 7. 本手册**没**覆盖 / 没验的部分(明账)
* **Authenticode / SmartScreen**:项目自有签名解决的是「这条自更新链是不是我们发的」,用户双击 exe 看到
的「未知发布者」与此无关(`signing.md` §0)。
* **托管签名**(如 SignPath):只是后续可替换路径,本手册与判据都不依赖它。
* **本文档里的命令没有被真跑过一遍**:`--generate-key` 会产出生产私钥,塔不生成、也不跑它;
②③ 两步要动 GitHub Secret 与 Rust 常量,同理。所以本手册是「按现有脚本的接口写的步骤」,
首次执行时**以脚本的实际输出为准**,跑完请回来修正本文档。
* **「用户机器上真的更新成功」**没有在本条里验:需要一次真 Release + 一台装着旧版本的机器。
* **ref 门只在本地验证过**(抽 `run:` 块用 Git Bash 跑 + 接线用例的文本变异),没有在真 Actions 上看过
`secrets` 的求值结果 —— 第一次真跑请确认非 main ref 的 ② 显示「未判定」。
## 8. 相关文件
| 文件 | 作用 |
|---|---|
| `packaging/check_signing_ready.py` | 三行就绪度 + `0/1/2` 退出码(只读、零第三方依赖) |
| `packaging/tests/signing_ready_selftest.py` | 判据自测:三行/退出码/两向变异重放/`--key` 用法错/指针章节 |
| `packaging/tests/signing_ready_wiring_selftest.py` | 接线自测:不是门禁 / 不被 `uses:` / **ref 门** / 文档路径存在性 + 三处变异 |
| `.github/workflows/signing-status.yml` | 把三行搬进 job summary 的**播报** workflow(不是门禁;种子只在 main 注入) |
| `packaging/docs/signing.md` | 为什么签名、威胁模型、轮换纪律、四条发布侧命令的红法对照表 |
| `packaging/sign_manifest.py` | 生成钥 / 签名 / 对账 / 回验的唯一实现(`--self-test` 跑 RFC 8032 向量) |
+160
View File
@@ -0,0 +1,160 @@
# 产物签名与更新验签(M190)
本文件是**发布侧手册**:私钥从哪来、怎么用、怎么轮换、哪一步失败必须让发布变红。
用户面的说明(README 里「为什么第一个包要手动下」那类话)不在本文件范围。
> **M255 补充:要照做的那几步在 [`signing-runbook.md`](signing-runbook.md)** —— 生成钥 → 私钥进 GitHub
> Secret(名字逐字是 `ULL_MANIFEST_SIGNING_KEY`)→ `PinnedKey` 贴进 `trust.rs` 标记之间 → 发一次带 `.sig`
> 的 Release → 用 `--verify-pinned` 回验 → 已知坑。本文件讲**为什么**,那份讲**怎么做**。
> 现状读数用 `python3 packaging/check_signing_ready.py`(三行 + `0/1/2`;未就绪时会打印一行修复命令);
> CI 里同一条读数挂在 `.github/workflows/signing-status.yml` 的 job summary 上 —— **那是播报,不是门禁**。
## 0. 先说清这件事**不**解决什么
| 有 / 没有 | 状态 |
|---|---|
| 更新器验「清单是不是我们发的」 | **有**(Ed25519 清单签名 + 二进制里钉住的公钥) |
| Windows 上「未知发布者」提示消失 | **没有**。项目自有签名与 Authenticode 是两件事 |
| SmartScreen 信誉 | **没有**。只有买了 OV/EV 证书并真签名才可能逐步积累 |
> 一句话:**项目自有签名保护的是自更新这条链,不是 Windows 的信任判断。** 用户双击 exe 时看到的
> 「未知发布者」来自 SmartScreen,与本文件的方案无关;买证书是另一条线(见 M190 报告里的
> 「后续要用户拍板的两件事」)。
## 1. 威胁模型(为什么要签名)
清单里的 `sha256` 只保证「包体与清单对得上」。**能改 Release 的人可以同时改包和摘要**,所以摘要
本身不是信任根。签名的对象因此是**清单文件的原始字节**:改了任意一个字段(哪怕只改版本号里一个数字),
签名立刻对不上。
```
发布侧 用户机器
------ --------
manifest.json(字节)──签名──> manifest.json.sig
│ │
└── 挂到同一个 Release ─────────────┤
├─ 取清单 + .sig
├─ 用**编译进二进制**的公钥验签(trust.rs)
├─ 过了 → 再解析、再比 sha256、再装
└─ 不过 → 五类签名错误,**什么都不会被安装**
```
## 2. 信任根在哪:`crates/core/src/update/trust.rs`
* 公钥是**编译期常量数组**(`PRODUCTION`),不是用户机器上的文件 —— 没有环境变量、没有设置键、
没有「清单旁边的 keyring.json」这类运行期口子(`tests/signature_cases.rs::no_runtime_keyring_override`
守着这条)。
* 是**数组**而不是一把:换签名方时新旧钥可以并存(见 §5)。
* `key_id` 只是查表标签,**判据永远是公钥字节**:把 `key_id` 伪造成钉住的那个也过不了
(`key_id_spoofing_does_not_help`)。
### 空表 = fail-closed
正式表当前**是空的**:私钥只能来自仓库 Secret,任何代码仓库都不该自己生成并落盘私钥。
空表的效果是「任何正式构建都拒装任何清单」,同时 `--check-pinned-keyring` 让发布流水线当场红。
**这是有意的**:宁可不出 Release,也不出未签名的 Release。
## 3. 一次性录入(只做一次)
```bash
# ① 生成:打印一段 Secret JSON + 一行可以直接贴进 trust.rs 的 PinnedKey
python3 packaging/sign_manifest.py --generate-key --key-id ull-release-2026a
```
* 打印出来的 **第一行 JSON**(含 `seed_hex`)= 私钥,**只**贴进仓库 Secret:
Settings → Secrets and variables → Actions → New repository secret,名字
**`ULL_MANIFEST_SIGNING_KEY`**。
* 打印出来的 **`PinnedKey { … }` 那一行**贴进 `crates/core/src/update/trust.rs` 的
`>>> PINNED-KEYS >>>` 与 `<<< PINNED-KEYS <<<` 标记之间(标记本身不要动,脚本按它解析)。
* 提交这次改动(公钥进 git 是对的:它是信任锚,本来就该公开可审计)。
### 私钥纪律(违反任何一条都算事故)
1. **只**存在于仓库 Secret `ULL_MANIFEST_SIGNING_KEY`。本地需要签测试产物时,走 `--key <文件>`
并把那个文件放在**仓库外**(gitignore 不保险,别放仓库里)。
2. **不得**出现在日志、产物、提交、issue、聊天记录里。CI 里任何 `echo`/`cat`/`set -x` 都不许碰到它
(工作流里的签名步骤显式 `set +x` 且只回显**公钥**)。
3. 一旦怀疑泄露:立即按 §5 轮换,并在下一个 Release 的说明里点名旧 `key_id` 作废。
4. 私钥文件/Secret 的备份不归仓库管;**丢了 = 旧钥作废**,用户机器上的旧二进制只能靠
「先手动装一次新版本」恢复(`trust.rs` 里的旧条目那时才可以删)。
## 4. 发布时发生什么(`.github/workflows/release-publish.yml`)
在 `manifest.json` 生成之后、`gh release create/upload` 之前,多了一步:
```bash
python3 packaging/sign_manifest.py --self-test # ① RFC 8032 向量自检
python3 packaging/sign_manifest.py --check-pinned-keyring # ② 密钥 ↔ trust.rs 必须对得上
python3 packaging/sign_manifest.py --sign dist/manifest.json # ③ 产出 dist/manifest.json.sig
python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # ④ 用钉住的公钥回验产出
```
第 ④ 步复现「客户端会怎么判」:它**不碰私钥**,只拿 `trust.rs` 里那把公钥去验刚落盘的 `.sig`。
因此「签名脚本自己说签好了」不构成证据,证据是「按客户端的判据能验过」。
四条命令任何一条非零退出 ⇒ **这一步红 ⇒ 整个 Release job 红 ⇒ 本次不出 Release**。常见红法:
| 现象 | 原因 | 怎么补 |
|---|---|---|
| `没有签名密钥` | Secret `ULL_MANIFEST_SIGNING_KEY` 没配 / 名字拼错 | 按 §3 录入 |
| `信任表是空的` | `trust.rs` 里还没钉任何公钥 | 把 §3 打印的 `PinnedKey` 那行贴进标记之间 |
| `签名钥(…)不在信任表里` | Secret 与 `trust.rs` 是**两把不同的钥** | 用同一把钥重跑 §3,或回滚 `trust.rs` 的改动 |
| `签出来的签名自己验不过` | 实现/环境异常 | 不要发布;把 `--self-test` 输出贴进 issue |
`dist/manifest.json.sig` 与 `dist/manifest.json` 一起作为 Release 资产上传(`assets=(...)` 那一行)。
## 5. 轮换(换钥 / 旧钥泄露 / 接托管签名)
**纪律:旧钥签新钥。** 不允许在没有旧钥签名的情况下替换信任表 —— 那等于让「能改仓库的人」变成
「能改信任根的人」。
1. 生成新钥(§3),得到 `key_id = ull-release-2027a`。
2. `trust.rs` 里**保留**旧条目、**追加**新条目(数组顺序无关;`key_id` 不能重名)。
3. 在一次发布里,让清单**同时带两把钥的签名**(两条 `signatures`,客户端「任意一条通过即放行」):
用旧钥签一次、新钥签一次,把两个信封的 `signatures` 数组拼起来放进 `manifest.json.sig`。
这一步让「装着旧二进制的用户」和「装着新二进制的用户」都过得去。
4. 之后至少**一个完整发布周期**保持双签名,再观察下载/更新失败率。
5. 确认新钥已被广泛接受后,用新钥单独签发下一次清单,并从 `trust.rs` 删掉旧条目
(删条目的那个版本必须**只**由新钥签)。
6. changelog 里点名:新增/作废的 `key_id` 与公钥指纹。
托管签名(例如 SignPath Foundation)作为**后续可替换路径**:它的产物签名由 CI 侧调用其服务完成,
`trust.rs` 的数组形状不用变(多一条 `PinnedKey` 即可);清单签名这一层保持本项目自有钥不动。
**不允许**为了接托管签名把判据改成「只认证书」。
## 6. 三条守门(都能单独变红)
| 守门 | 脚本 / 用例 | 变红意味着 |
|---|---|---|
| 版本资源真的在产物里 | `packaging/check_pe_version.py <exe> --expect-version <版本>` | 读回产物字节找不到 `VS_VERSIONINFO` / 字段值不对(构建日志绿也没用) |
| 篡改清单 ⇒ 验签失败且不安装 | `cargo test -p unlucky-core --lib update::tests::signature_cases` | 改字段/只改签名/漏签名/陌生钥 任一仍能装 |
| 签名失败阻断发布 | `release-publish.yml` 的签名步 + `--check-pinned-keyring` | 没钥/钥没钉/钥不匹配时仍发出 Release |
另外 CI 的构建槽位会跑 `check_pe_version.py`(`release-build.yml`):Windows 槽位上直接读
`target/release/unlucky-launcher-iced.exe` 与 `ull.exe` 的字节,断言 `CompanyName` 等字符串在。
## 7. 实现分工(M190 r1 复审后)
| 哪一半 | 在哪 | 用什么 |
|---|---|---|
| **验签**(进二进制) | `crates/core/src/update/ed25519.rs` | `ed25519-dalek::verify_strict`(在非 strict 的基础上额外拒**小阶公钥**与**小阶 `R`**;`S` 规范性是非 strict 的 `verify` 也查的) |
| **签名**(只在发布侧) | `packaging/ed25519_ref.py` + `packaging/sign_manifest.py` | 纯 Python 参考实现,零第三方依赖(CI 不装 pip 包) |
两套实现互相独立,`--self-test` 用 RFC 8032 §7.1 的同一组向量把两边各自钉住。**第一版曾把验签写成
仓内自写**(5x51 素域 + 扩展坐标);r1 复审裁定不安全——信任锚的微妙错误后果是「验签通过但本不该通过」,
因此整段删除,改为官方实现。`crates/core/src/update/tests/ed25519_cases.rs` 现在钉的是
**dalek 的 strict 语义**:谁把 `verify_strict` 换成 `verify`,`small_order_public_key_is_rejected` 与
`small_order_r_is_rejected_only_by_strict` 会红(这是 r2 实测的结论——其余几条拒绝类用例在非 strict
下仍绿,别把它们当 strict 的守卫)。
## 8. 本地自测(不需要私钥)
```bash
python3 packaging/sign_manifest.py --self-test # RFC 8032 向量 + 标记解析
python3 packaging/check_pe_version.py --self-test # 口径表与 pe_version.rs 对拍
cargo test -p unlucky-core --lib update::tests::signature_cases
cargo test -p unlucky-core --lib update::tests::ed25519_cases
```
带 `update-test-source` 的验收构建里另有一把**公开的测试钥**(种子写在测试夹具里):它只用于
「本地目录源 + 真二进制」的端到端验收,**不在**正式信任表里,签不出任何正式版本能装的清单。
+279
View File
@@ -0,0 +1,279 @@
# Termux + termux-x11 上的 glibc 构建(试验级)
本页记录**在 Termux 里构建 glibc 版 GUI,并在 termux-x11 里跑起来**的可复现步骤。
2026-11-06 在一台 OnePlus 15(Android 17、Termux、KirinSU/root 环境)上实测通过:
`--software-renderer --screenshot` 出 1180×760 PNG,中文正常,`msa client_id=configured source=build`。
**定位**:试验级、不承诺。发布矩阵里没有面向 Termux 的资产;本文只描述源码自建路径。
GUI 只在上述设备 + CPU 渲染(tiny-skia)上验证过,GPU 路径见 §5(尚未在 glibc 侧验证)。
## 1. 为什么不能直接 `cargo build`
Termux 的 `rustc` host 是 `aarch64-linux-android`(`target_os = "android"`):
- `crates/app-iced/Cargo.toml` 的 `x11` / `wayland` feature 只挂在 `cfg(target_os = "linux")` 下;
- iced 的窗口层 winit 0.30 在 android target 上走 Android(NativeActivity)后端,
会拉 `android-activity` 并要求 `game-activity`/`native-activity` feature,普通进程编不过也跑不了。
所以要在 Termux 里产出一个 **`aarch64-unknown-linux-gnu`(glibc)** 二进制,靠 Termux 的
[glibc 运行时](https://packages.termux.dev/apt/termux-glibc/) 原生执行。
## 2. 工具链(一次性)
以下路径用变量表示,实际可放在任意本地目录(示例放在仓库内的 `.gnu/`,已 gitignore):
```sh
PREFIX="${PREFIX:-/data/data/com.termux/files/usr}"
G="$PWD/.gnu"
```
### 2.1 glibc 运行时
确认 `$PREFIX/glibc/lib/ld-linux-aarch64.so.1` 存在(`pkg install glibc-runner` 或装过 glibc 仓库
的包即可)。**不需要 `gcc-glibc`**:链接用 Termux 自带的 clang + lld,glibc 的 CRT/头文件由
`glibc` 包提供。
### 2.2 Rust gnu 工具链
下载 Rust 1.99.1(或与 `rust-version` 兼容的 stable)的 `rustc` / `rust-std` / `cargo`,
以及跑门禁需要的 `clippy` / `rustfmt` 五个 `aarch64-unknown-linux-gnu` 组件。官方源在国内很慢,
用 USTC 镜像:
```sh
mkdir -p "$TMPDIR/ull-gnu" && cd "$TMPDIR/ull-gnu"
for c in rustc rust-std cargo clippy rustfmt; do
curl -fLO "https://mirrors.ustc.edu.cn/rust-static/dist/$c-1.99.1-aarch64-unknown-linux-gnu.tar.xz"
done
```
解包后按组件布局合并到 `$G/toolchain`(`rustc/bin`、`cargo/bin` → `toolchain/bin`,
两者的 `lib/` → `toolchain/lib`,`rust-std-*/lib/rustlib/aarch64-unknown-linux-gnu` →
`toolchain/lib/rustlib/`)。
### 2.3 改 interpreter / rpath
工具链里的 `rustc`、`cargo`、`rustdoc` 是 glibc ELF,默认 interpreter 是 `/lib/ld-linux-*.so.1`,
在 Termux 上不存在:
```sh
LD="$PREFIX/glibc/lib/ld-linux-aarch64.so.1"
for f in rustc cargo rustdoc clippy-driver cargo-clippy rustfmt cargo-fmt; do
patchelf --set-interpreter "$LD" --set-rpath "$PREFIX/glibc/lib:$G/toolchain/lib" \
"$G/toolchain/bin/$f"
done
```
> **坑(实测)**:`cargo clippy` 会在 PATH 里找 `cargo-clippy`。如果只装了 Termux 的 Rust,
> 找到的是 android 版 `cargo-clippy`,`aws-lc-sys` 等构建脚本会被当成
> `--target=aarch64-linux-android` 编译并失败。把 gnu 工具链的 `bin` 放在 PATH 最前面即可;
> `rustc`/`rustfmt` 同理。
### 2.4 gcc 运行时片段
`clang -fuse-ld=lld` 仍然需要 `crtbeginS.o` / `crtendS.o` / `libgcc.a`。从 Debian 的
`libgcc-14-dev`(arm64)取即可(只解包,不安装):
```sh
curl -fLO https://deb.debian.org/debian/pool/main/g/gcc-14/libgcc-14-dev_14.2.0-19_arm64.deb
dpkg-deb -x libgcc-14-dev_14.2.0-19_arm64.deb "$G/gnu-support"
# crt/libgcc 在 $G/gnu-support/usr/lib/gcc/aarch64-linux-gnu/14/
```
### 2.5 linker 驱动
写一个 `$G/bin/cc-glibc`(`chmod +x`):
```sh
#!/data/data/com.termux/files/usr/bin/sh
unset LD_PRELOAD
P=$PREFIX
S="<仓库>/.gnu/gnu-support/usr/lib/gcc/aarch64-linux-gnu/14"
C="clang --target=aarch64-unknown-linux-gnu --sysroot=/ -isystem $P/glibc/include \
-B$P/glibc/lib -B$S -L$S -L$P/glibc/lib -fuse-ld=lld"
for a in "$@"; do
case "$a" in -c|-E|-S) exec $C "$@" ;; esac
done
exec $C -Wl,--dynamic-linker=$P/glibc/lib/ld-linux-aarch64.so.1 -Wl,-rpath,$P/glibc/lib "$@"
```
要点:`--sysroot=/` 而不是 `--sysroot=$PREFIX/glibc`——glibc 的 `libc.so` 是含绝对路径的
linker script,带 sysroot 会被二次拼接;`-B $P/glibc/lib` 让 clang 找到 `Scrt1.o`/`crti.o`/`crtn.o`。
## 3. 构建
```sh
unset LD_PRELOAD # 必须:Termux 的 libtermux-exec 会被 glibc loader 当成坏 ELF
G="$PWD/.gnu"
export PATH="$G/toolchain/bin:$PATH"
export CARGO_TARGET_DIR="$G/target"
export RUSTFLAGS="-C linker=$G/bin/cc-glibc"
export CC="$G/bin/cc-glibc" CXX="$G/bin/cc-glibc" AR="$PREFIX/glibc/bin/ar"
export CARGO_HTTP_CAINFO="$PREFIX/etc/tls/cert.pem" # glibc 版 cargo 不认 Android 的 CA 路径
export ULL_MSA_CLIENT_ID=<id> # 可选;烘微软登录 id,值不进 git
# crates 走 USTC 稀疏索引(官方源在部分网络下只有 ~20 KB/s)
cargo --config 'source.crates-io.replace-with="ustc"' \
--config 'source.ustc.registry="sparse+https://mirrors.ustc.edu.cn/crates.io-index/"' \
build --release -p unlucky-app-iced --no-default-features
```
`--no-default-features` 关掉 `gpu`,只编 tiny-skia(CPU)。产物:`.gnu/target/release/unlucky-launcher-iced`。
门禁(与 CI 同一组命令,环境同上):
```sh
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --all-targets --no-fail-fast
```
Termux 上有三条**环境差异**失败(不是回归):`launch` 测试读
`/proc/<pid>/task/<pid>/children`(Android 内核没有)、natives 事件的长路径被脱敏成
`<opaque>`、`settings_bridge` 的 mtime 热重载偶发(flaky)。PC 的 Linux CI 不受影响。
## 4. 运行(termux-x11)
```sh
# 一次性:glibc 版 X 运行库(cf 源慢时可把 sources.list 里的 packages-cf 换成 packages.termux.dev)
pkg install libx11-glibc libxkbcommon-glibc fontconfig-glibc \
libxcursor-glibc libxrandr-glibc libxi-glibc libxext-glibc \
libxcb-glibc libwayland-glibc
termux-x11 :0 -ac & # 配合已安装的 termux-x11 App 使用
DISPLAY=:0 .gnu/target/release/unlucky-launcher-iced --software-renderer
```
无头取证(CI 同款通道):
```sh
DISPLAY=:0 .gnu/target/release/unlucky-launcher-iced \
--software-renderer --screenshot /tmp/ull-termux.png --data-dir /tmp/ull-termux-data
```
- 中文:GUI 在 Termux 上会把 `/system/fonts/NotoSansCJK-Regular.ttc` 注册进 cosmic-text
(fontdb 不扫 Android 的字体目录),无需额外环境变量。
- 启动日志里 `[auth] msa client_id=configured source=build` 表示构建期 id 已烘入。
- **横屏优先**:Termux 上默认窗口尺寸会按当前 X 屏幕等比自适应(日志
`[window] ... source=termux-screen`),横屏铺满、竖屏也不溢出;验收尺寸 1180×760 仍是
默认基准,`ULL_WINDOW_SIZE=1520x900` 这类覆盖仍然优先。
### 4.1 Termux 上的 Java
`ull java install <major>` 在 Termux 上**默认先用系统包**(`openjdk-17/21/25`):
- 内置 SAU / TUNA / USTC / 官方四个 termux-main 镜像,按顺序尝试;
- `apt-get` 的 lists/cache 隔离在 `$PREFIX/tmp/ull-java-termux/`,不改用户的 apt 配置;
装完自动清理该目录(一次安装约 100 MB 临时占用);
- 装完从 `$PREFIX/lib/jvm/java-<major>-openjdk` 发现,`ull java list` 直接可见;
- `--vendor termux` 只走系统包;`temurin|oracle|microsoft|zulu` 仍走归档下载链。
- 已实测:`ull java install 17` 从 SAU 镜像装完 `openjdk-17`(含 `openjdk-17-x`),
约 20 秒;`ull java list` 随后列出 `[system]` 的 17。
### 4.2 跑 MC 需要的运行时(已手动验证)
bionic 的 Termux JVM 加载不了 MC 的 glibc natives,所以 MC 要用 **glibc JVM**:
```sh
# Temurin 21 JRE(linux-aarch64),用 glibc loader 直接跑
curl -L -o jre21.tar.gz \
https://api.adoptium.net/v3/binary/latest/21/ga/linux/aarch64/jre/hotspot/normal/eclipse
tar -xzf jre21.tar.gz -C "$G/jre"
JRE="$G/jre/jdk-21.0.13+9-jre"
$PREFIX/glibc/lib/ld-linux-aarch64.so.1 \
--library-path "$JRE/lib:$JRE/lib/server:$PREFIX/glibc/lib" "$JRE/bin/java" -version
```
- AWT/X11:`DISPLAY=:0` 下 Swing 窗口正常(`headless=false`、`swing-ok`)。
- GL:需要 §5 的 Mesa 26 GL 构建(`-Dgallium-drivers=zink,softpipe -Dglx=dri`)。
`zink` 目前卡在 `DRI3 not available`;**softpipe 可用**:
`MESA_LOADER_DRIVER_OVERRIDE=swrast LIBGL_ALWAYS_SOFTWARE=1`。
- 端到端探针:LWJGL 3.3.3 arm64 natives + GLFW + GLX → `GL_VERSION=3.3 (Compatibility
Profile) Mesa 26.2.6`、`GL_RENDERER=softpipe`、`gl-ok`。
> **启动器已接线**(本分支):
> - Linux/aarch64 上 LWJGL3 的 natives 自动改写成 `natives-linux-arm64`
> (Mojang 的 `libraries.minecraft.net` 对 arm64 一律 404,下载兜底到阿里云 Maven 镜像);
> - Termux glibc 构建启动 MC 时,子进程默认带 `MESA_LOADER_DRIVER_OVERRIDE=swrast`
> 与 `LIBGL_ALWAYS_SOFTWARE=1`(用户自定义环境变量仍可覆盖)。
>
> **还差一步**:把实例的 Java 指到 glibc JRE。Temurin 解包后 `bin/java` 默认的
> interpreter 在 Android 上不存在,先 patchelf 一次即可直接执行:
>
> ```sh
> patchelf --set-interpreter "$PREFIX/glibc/lib/ld-linux-aarch64.so.1" \
> --set-rpath "$PREFIX/glibc/lib:$JRE/lib:$JRE/lib/server" "$JRE/bin/java"
> "$JRE/bin/java" -version
> ```
>
> 之后在实例设置里把 Java 指向 `$JRE/bin/java`(或 `--java`)即可。
>
> **真机验证(1.20.4)**:`ull install 1.20.4 --name mc-test`(740 MB)成功后,
> `ull launch mc-test --java "$JRE/bin/java" --account Tester`,
> MC 主菜单在 termux-x11 中渲染(窗口 854×480;日志在实例目录
> `versions/1.20.4/logs/latest.log`)。非致命项:narrator 的 flite 缺失、
> LWJGL JNI 版本告警、oshi 的 cpuVendor NPE(都未阻断启动)。
## 5. GPU(已打通:自编 Mesa 26.2.6 + KGSL)
termux-x11 的 X 客户端可以走硬件;glibc 侧的问题在**驱动**,不在 loader:
1. `vulkan-icd-loader-glibc` + ICD JSON 本身正常。
2. Termux 软件源的 glibc Mesa 24.2.6 不认识 Adreno 840
(`chip_id = 44050A31 is unsupported`)。
3. Arch 的 `vulkan-freedreno 26.2.6` 能加载,但它是 Linux/DRM 构建,缺 KGSL
(`/dev/dri` 对 `untrusted_app` 被 SELinux 拒绝)。
**解法:给 glibc 自编一份带 KGSL 的 Mesa 26.2.6。** 本机实测通过:
```sh
# 1) 源码 + 依赖
# mesa-26.2.6.tar.xz(archive.mesa3d.org)
# Debian libstdc++-14-dev 的头($G/gnu-support/usr/include/c++/14)
# meson / ninja(pip 装 meson)
# 2) 把源码里 *.py 的 shebang 从 #!/usr/bin/env python3 改成 Termux python3
# (Android 没有 /usr/bin/env)
# 3) meson setup(cc-glibc 包装器:-c 不带链接参数、C++ 加 -stdlib=libstdc++、
# 共享库链接加 -lstdc++;详见 §3)
meson setup build3 --native-file="$G/mesa-native.ini" --wrap-mode=nofallback \
-Dbuildtype=release -Dstrip=true -Dplatforms=x11 \
-Dvulkan-drivers=freedreno -Dfreedreno-kmds=kgsl \
-Dgallium-drivers=zink,softpipe -Dglx=dri -Dopengl=true -Degl=enabled \
-Dgles1=disabled -Dgles2=enabled -Dllvm=disabled -Dgbm=disabled \
-Dvideo-codecs= -Dzstd=enabled -Dbuild-tests=false
ninja -C build3
```
`--wrap-mode=nofallback` 是为了跳过 libarchive 子项目(它只被 freedreno 的 decode 工具需要,
且它自带的 `id_t` 兜底宏会和 glibc 头冲突)。
安装与启用:
```sh
cp build3/src/freedreno/vulkan/libvulkan_freedreno.so \
"$PREFIX/glibc/lib/libvulkan_freedreno_ull.so"
cat > "$PREFIX/glibc/share/vulkan/icd.d/ull_freedreno.json" <<EOF
{"ICD":{"api_version":"1.4.335","library_path":"$PREFIX/glibc/lib/libvulkan_freedreno_ull.so"},"file_format_version":"1.0.0"}
EOF
```
应用启动时(glibc 构建 + Termux + 该 JSON 存在)会自动把它设进
`VK_ICD_FILENAMES` / `VK_DRIVER_FILES`(`app/boot/termux_gpu.rs`);用户显式设了这两个
变量时不覆盖。实测日志:
```text
[probe] adapters=1 [Adreno (TM) 840 [IntegratedGpu]] hardware=true :: request=auto -> ICED_BACKEND=wgpu
[gpu] Selected: AdapterInfo { name: "Adreno (TM) 840", driver: "turnip Mesa driver",
driver_info: "Mesa 26.2.6", backend: Vulkan }
[shot] 1657x1067 renderer=wgpu,首帧 61 ms
```
没装驱动/没有 GPU 时仍会打印具名错误并自动降级 tiny-skia(同一进程不能重建事件循环,
降级走 respawn),所以 GPU 变体在纯 CPU 环境也能跑。
## 6. 已知边界
- 没有面向 Termux 的发布资产,也不在 CI 矩阵里;每次都要源码自建。
- 只在 CPU 渲染 + 单台设备(Adreno 840 / Android 17)上验证过。
- **装好 JDK ≠ 能跑 MC**:MC 的 LWJGL natives 是 glibc `.so`,bionic 的 Termux JVM 加载不了;
这条与「启动器 GUI 能显示」是两件事,未在本文覆盖。
- `ULL_MSA_CLIENT_ID` 属于仓库 Secret,构建时从环境/Secret 注入,**不写进任何 git 文件**。
+142
View File
@@ -0,0 +1,142 @@
"""Ed25519 的**参考实现**(RFC 8032),只给发布侧签名脚本用(M190)。
为什么这里自写一套:发布侧签名必须**零第三方依赖**(CI 不装 pip 包),而仓里 `crates/core` 的验签
用的是 `ed25519-dalek`。两套实现互相独立,`sign_manifest.py --self-test` 用 RFC 8032 §7.1 的同一组
向量把两边各自钉住 —— 任何一边坏了,向量那一半会红;两边都「一致地错」的概率极低。
⚠ 这里的私钥路径(`sign`)**只在发布侧**(`packaging/sign_manifest.py --sign`)跑,不进任何产物;
二进制里只有验签(`ed25519-dalek::verify_strict`)。扩展坐标公式与 dalek/ref10 同源,向量自检兜底。
import ed25519_ref as ref
seed = bytes.fromhex("…") # 32 字节种子
pk = ref.public_key(seed)
sig = ref.sign(seed, b"message") # 64 字节
assert ref.verify(pk, b"message", sig)
"""
from __future__ import annotations
import hashlib
ALG = "ed25519"
Q = 2**255 - 19
L = 2**252 + 27742317777372353535851937790883648493
def _inv(x: int) -> int:
return pow(x, Q - 2, Q)
D = -121665 * _inv(121666) % Q
_D2 = 2 * D % Q
_SQRT_M1 = pow(2, (Q - 1) // 4, Q)
def _recover_x(y: int, sign: int) -> int | None:
u = (y * y - 1) % Q
v = (D * y * y + 1) % Q
v3 = pow(v, 3, Q)
v7 = pow(v, 7, Q)
x = u * v3 % Q * pow(u * v7 % Q, (Q - 5) // 8, Q) % Q
if (v * x * x - u) % Q != 0:
x = x * _SQRT_M1 % Q
if (v * x * x - u) % Q != 0:
return None
if x % 2 != sign:
x = (Q - x) % Q
return x
def _decompress(data: bytes) -> tuple[int, int, int, int] | None:
if len(data) != 32:
return None
y = int.from_bytes(data, "little")
sign = (y >> 255) & 1
y &= (1 << 255) - 1
if y >= Q:
return None
x = _recover_x(y, sign)
if x is None:
return None
return (x, y, 1, x * y % Q)
def _add(p: tuple[int, int, int, int], r: tuple[int, int, int, int]):
x1, y1, z1, t1 = p
x2, y2, z2, t2 = r
a = (y1 - x1) * (y2 - x2) % Q
b = (y1 + x1) * (y2 + x2) % Q
c = t1 * _D2 * t2 % Q
d = 2 * z1 * z2 % Q
e, f, g, h = (b - a) % Q, (d - c) % Q, (d + c) % Q, (b + a) % Q
return (e * f % Q, g * h % Q, f * g % Q, e * h % Q)
def _double(p: tuple[int, int, int, int]):
x1, y1, z1, _ = p
a = x1 * x1 % Q
b = y1 * y1 % Q
c = 2 * z1 * z1 % Q
d = -a % Q
e = ((x1 + y1) * (x1 + y1) - a - b) % Q
g = (d + b) % Q
f = (g - c) % Q
h = (d - b) % Q
return (e * f % Q, g * h % Q, f * g % Q, e * h % Q)
def _scalarmult(p, e: int):
acc = (0, 1, 1, 0)
for bit in bin(e)[2:] if e else "":
acc = _double(acc)
if bit == "1":
acc = _add(acc, p)
return acc
def _encode(p) -> bytes:
x, y, z, _ = p
zi = _inv(z)
x, y = x * zi % Q, y * zi % Q
return (y | ((x & 1) << 255)).to_bytes(32, "little")
def _base():
encoded = (4 * _inv(5) % Q).to_bytes(32, "little")
return _decompress(encoded)
def _hint(data: bytes) -> int:
return int.from_bytes(hashlib.sha512(data).digest(), "little") % L
def public_key(seed: bytes) -> bytes:
h = hashlib.sha512(seed).digest()
a = int.from_bytes(_clamp(bytearray(h[:32])), "little")
return _encode(_scalarmult(_base(), a))
def _clamp(scalar: bytearray) -> bytes:
scalar[0] &= 248
scalar[31] &= 127
scalar[31] |= 64
return bytes(scalar)
def sign(seed: bytes, message: bytes) -> bytes:
if len(seed) != 32:
raise SystemExit("error: Ed25519 种子必须 32 字节")
h = hashlib.sha512(seed).digest()
a = int.from_bytes(_clamp(bytearray(h[:32])), "little")
prefix = h[32:]
r = _hint(prefix + message)
r_encoded = _encode(_scalarmult(_base(), r))
pk = _encode(_scalarmult(_base(), a))
k = _hint(r_encoded + pk + message)
s = (r + k * a) % L
return r_encoded + s.to_bytes(32, "little")
def verify(pk: bytes, message: bytes, signature: bytes) -> bool:
if len(pk) != 32 or len(signature) != 64:
return False
r_bytes, s_bytes = signature[:32], signature[32:]
if int.from_bytes(s_bytes, "little") >= L:
return False
a = _decompress(pk)
r_point = _decompress(r_bytes)
if a is None or r_point is None:
return False
k = _hint(r_bytes + pk + message)
left = _scalarmult(_base(), int.from_bytes(s_bytes, "little"))
return _encode(left) == _encode(_add(r_point, _scalarmult(a, k)))
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# 本地复跑 `.github/workflows/ci.yml` 的两条 **ubuntu 门禁**:`clippy --workspace --all-targets
# -- -D warnings` 与 `test --workspace --all-targets`。
#
# 为什么要有这条脚本(与 `linux-smoke-docker.sh` 同一动机,但受检对象不同):
# - smoke 脚本复刻的是 `runtime-smoke` job(能不能真跑起来);本脚本复刻的是**编译期门禁**
# (能不能编过 / 测过)。M144 的三条红里有两条(clippy ubuntu、test ubuntu)属于后者,
# 而它们只在「Linux + `--all-targets` + `-D warnings`」三件事凑齐时才炸——
# 在 Windows 本机永远复现不了(Windows 上那些 `#[cfg(target_os = "windows")]` 分支都有使用者)。
# 所以这条脚本是「本机提前挡住那两条红」的唯一通道。
#
# 用法:
# bash packaging/linux-check-docker.sh [仓库根目录]
# ULL_CHECK_STAGES=clippy bash packaging/linux-check-docker.sh # 只跑 clippy
# ULL_CHECK_TEST_ARGS='-p unlucky-app-iced --lib' \
# ULL_CHECK_STAGES=test bash packaging/linux-check-docker.sh # 只跑一个 crate 的单测
#
# 前置:基础镜像 `ull-linux-smoke:bookworm`(由 `packaging/linux-smoke-docker.sh` 建)。
# 本脚本在它之上只加一层 `cmake` + `ninja-build`:`aws-lc-sys`(rustls 的汇编后端)
# 缺 cmake 编不过,M144 实测过。
#
# 诚实边界(别把它当 runner 的等价物):
# - 基础镜像是 **Debian 12(glibc 2.36)**,官方 runner 是 **Ubuntu 24.04(glibc 2.39)**;
# 两者 rustc 走同一份 `rust-toolchain.toml`(stable),所以**编译期 lint 与单测判定**可对齐,
# 但发行版差异面(系统库版本、字体)不受本脚本保护。
# - 本脚本只跑 ci.yml 里那两条命令,不跑 fmt / build / runtime-smoke。
# - 冷编很贵(全 workspace + `--all-targets`,M144 实测 15–25 分钟)。缓存卷 `ull-linux-check-target`
# 第二次起是热的;**别**把宿主机的 target 目录挂进来(路径与指纹都对不上,等于骗自己)。
set -euo pipefail
repo_root=$(cd "${1:-.}" && pwd)
base_image=${ULL_SMOKE_IMAGE:-ull-linux-smoke:bookworm}
image=${ULL_CHECK_IMAGE:-ull-linux-check:bookworm}
cargo_volume=${ULL_CARGO_VOLUME:-ull-cargo-reg}
target_volume=${ULL_CHECK_TARGET_VOLUME:-ull-linux-check-target}
apt_mirror=${ULL_APT_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn}
stages=${ULL_CHECK_STAGES:-clippy test}
test_args=${ULL_CHECK_TEST_ARGS:---workspace --all-targets --no-fail-fast}
fail_fast=${ULL_CHECK_FAIL_FAST:-0}
# Git Bash 里路径是 MSYS 虚拟路径:直接喂给 `docker -v` 会被当成容器内路径(M62 踩过)。
if command -v cygpath >/dev/null 2>&1; then
repo_root=$(cygpath -m "$repo_root")
fi
echo "== 仓库: $repo_root"
echo "== 阶段: $stages(test 参数: $test_args)"
if ! docker image inspect "$base_image" >/dev/null 2>&1; then
echo "!! 缺基础镜像 $base_image —— 先跑一次:" >&2
echo " bash packaging/linux-smoke-docker.sh . D:/tmp/ull-smoke" >&2
echo " (它建的镜像里已经带了 ci.yml 那批图形栈编译依赖;本脚本只在其上加 cmake/ninja)" >&2
exit 2
fi
# 一层派生镜像:cmake + ninja-build。只在缺镜像或显式 ULL_CHECK_REBUILD_IMAGE=1 时重建。
if ! docker image inspect "$image" >/dev/null 2>&1 || [ "${ULL_CHECK_REBUILD_IMAGE:-0}" = "1" ]; then
context=$(mktemp -d)
trap 'rm -rf "$context"' EXIT
cat >"$context/Dockerfile" <<EOF
FROM $base_image
# 基础镜像已把 apt 源改到 $apt_mirror(见 linux-smoke-docker.sh)。
# aws-lc-sys(rustls 的汇编后端)构建时要 cmake + ninja,缺了就是在 C 依赖里报错退出,
# 与 Rust 侧代码无关——别被那种红骗去改源码(M144 实测)。
RUN apt-get update \\
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\
cmake ninja-build \\
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
EOF
docker build -t "$image" "$context"
fi
# 缓存卷挂到 `registry` 子目录:直接挂 /usr/local/cargo 会盖掉镜像里的 cargo 本体(M62 踩过)。
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$repo_root:/src" \
-v "$cargo_volume:/usr/local/cargo/registry" \
-v "$target_volume:/target" \
-w /src \
-e CARGO_TARGET_DIR=/target \
-e CARGO_TERM_COLOR=never \
-e ULL_CHECK_STAGES="$stages" \
-e ULL_CHECK_TEST_ARGS="$test_args" \
-e ULL_CHECK_FAIL_FAST="$fail_fast" \
"$image" bash -euo pipefail -c '
rustc --version && cargo --version && cat /etc/debian_version
cmake --version | head -1 && ninja --version
run_clippy() { cargo clippy --workspace --all-targets -- -D warnings; }
# 故意不加引号:ULL_CHECK_TEST_ARGS 是一串参数(默认照抄 ci.yml 的那条命令)。
run_test() { cargo test $ULL_CHECK_TEST_ARGS; }
overall=0
for stage in $ULL_CHECK_STAGES; do
case "$stage" in
clippy) label="cargo clippy --workspace --all-targets -- -D warnings"; run=run_clippy ;;
test) label="cargo test $ULL_CHECK_TEST_ARGS"; run=run_test ;;
*) echo "!! 未知阶段: $stage(可选 clippy / test)" >&2; exit 2 ;;
esac
echo "== 开始: $label"
set +e
"$run"
code=$?
set -e
# 退出码单独成行:一眼能看出是「编译期 lint」还是「单测」,也能与 CI 注解里的码对照。
echo "== 结束: $label → exit=$code"
if [ "$code" -ne 0 ]; then
overall=1
if [ "$ULL_CHECK_FAIL_FAST" = "1" ]; then exit "$code"; fi
fi
done
echo "== 汇总: $ULL_CHECK_STAGES → exit=$overall"
exit "$overall"
'
echo "== 两条 ubuntu 门禁已跑完(上表 exit=0 才算绿)"
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# 本地复跑 `.github/workflows/ci.yml` 的 `runtime-smoke` job:Linux + Xvfb + 软渲染截图。
#
# 为什么要有这条脚本:CI 的 runtime-smoke 在 GitHub 的 ubuntu-latest 上跑,
# 但「改动这个 job 的人」需要在没有 Linux 桌面的开发机上先自证一遍——否则改的就是没人验过的 YAML。
# 本脚本在 Docker 里复刻 job 的步骤(同样的 apt 依赖、同样的构建、同样的 xvfb-run 命令),
# 并把截图与判定结果留在宿主目录,供人眼与 `check_screenshot.py` 复核。
#
# 用法:
# bash packaging/linux-smoke-docker.sh [仓库根目录] [输出目录]
# ULL_SMOKE_SKIP_BUILD=1 bash packaging/linux-smoke-docker.sh # 复用缓存的二进制
#
# 说明(诚实边界):
# - 基础镜像 `rust:1-bookworm` 是 **Debian 12(glibc 2.36)**,官方 runner 是 **Ubuntu 24.04(glibc 2.39)**。
# 两者图形栈(X11/xvfb/Mesa 软渲染)来源不同,因此本脚本证明的是「Linux 上这条通道能真跑出图」,
# 不能代替官方 runner 的首跑验证。
# - 国内网络慢时可换 TUNA 镜像:脚本默认就用 TUNA 的 Debian 源(`ULL_APT_MIRROR` 可覆盖)。
# - 受检 bin 是 iced 版 `unlucky-launcher-iced`,与 `ci.yml` 的 runtime-smoke 逐步对应(M118:Slint 版
# bin/包 `unlucky-launcher`/`unlucky-app` 已随 `crates/app` 退出 workspace,改了这里要两边一起改)。
set -euo pipefail
repo_root=$(cd "${1:-.}" && pwd)
out_dir=${2:-ull-smoke}
image=${ULL_SMOKE_IMAGE:-ull-linux-smoke:bookworm}
cargo_volume=${ULL_CARGO_VOLUME:-ull-cargo-reg}
target_volume=${ULL_CARGO_TARGET_VOLUME:-ull-linux-target}
apt_mirror=${ULL_APT_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn}
rustup_mirror=${ULL_RUSTUP_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn/rustup}
mkdir -p "$out_dir"
out_dir=$(cd "$out_dir" && pwd)
# Git Bash 里 /tmp、/d/x 都是 MSYS 虚拟路径:直接喂给 `docker -v` 会被当成 Linux VM 内的路径,
# 结果「脚本说成功、宿主机什么都没有」。有 cygpath 就统一转成 Windows 混合路径(D:/x)并回显真实位置。
if command -v cygpath >/dev/null 2>&1; then
repo_root=$(cygpath -m "$repo_root")
out_dir=$(cygpath -m "$out_dir")
fi
echo "== 仓库: $repo_root"
echo "== 输出: $out_dir(截图 $out_dir/runtime-smoke.png)"
# 1) 构建镜像:rust 工具链 + xvfb + 图形栈编译依赖 + 字体(软渲染器要从系统字体库取字形)
if ! docker image inspect "$image" >/dev/null 2>&1 || [ "${ULL_SMOKE_REBUILD_IMAGE:-0}" = "1" ]; then
context=$(mktemp -d)
trap 'rm -rf "$context"' EXIT
cat >"$context/Dockerfile" <<EOF
FROM rust:1-bookworm
# 工具链组件(rustfmt/clippy)在 rust-toolchain.toml 里是必备项,rustup 会去补齐;
# 默认 dist 服务器在国内会挂成「rustc 0.6% CPU 睡 12 分钟」的假死(M62 实测),所以走 TUNA。
ENV RUSTUP_DIST_SERVER=$rustup_mirror \\
RUSTUP_UPDATE_ROOT=$rustup_mirror/rustup
RUN sed -i 's|http://deb.debian.org|$apt_mirror|g' /etc/apt/sources.list.d/debian.sources \\
&& apt-get update \\
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\
xvfb xauth python3 \\
libxkbcommon-dev libxkbcommon-x11-0 libfontconfig1-dev \\
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev \\
libx11-6 libxcursor1 libxrandr2 libxi6 \\
fonts-dejavu-core fonts-liberation2 \\
&& rm -rf /var/lib/apt/lists/*
RUN rustup component add rustfmt clippy && rustup toolchain list
WORKDIR /src
EOF
docker build -t "$image" "$context"
fi
# 2) 跑容器:构建 → xvfb 截图 → 像素判定
# 注意缓存卷要挂到 `registry` 子目录:直接挂 /usr/local/cargo 会把镜像里的 `bin/`(cargo 本体)盖掉,
# 变成 `cargo: command not found`(M62 实测踩到)。
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$repo_root:/src" \
-v "$cargo_volume:/usr/local/cargo/registry" \
-v "$target_volume:/target" \
-v "$out_dir:/out" \
-w /src \
-e CARGO_TARGET_DIR=/target \
-e ULL_SMOKE_SKIP_BUILD="${ULL_SMOKE_SKIP_BUILD:-0}" \
-e CARGO_TERM_COLOR=never \
"$image" bash -euo pipefail -c '
rustc --version && cargo --version && cat /etc/debian_version
if [ "$ULL_SMOKE_SKIP_BUILD" != "1" ]; then
# 共享热缓存卷可能喂陈旧产物(踩坑记录 N7):先清掉受检 crate 再编
# 清不掉就算了(N7 兜底用);CI 里**没有**对应两行,别误抄进 ci.yml —— 那边每次都是干净 runner
cargo clean -p unlucky-app-iced || true
cargo build --bin unlucky-launcher-iced --locked -j 6
fi
mkdir -p /out/data
set +e
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
/target/debug/unlucky-launcher-iced --software-renderer \
--screenshot /out/runtime-smoke.png --data-dir /out/data
code=$?
set -e
echo "exit=$code"
python3 packaging/check_screenshot.py /out/runtime-smoke.png
exit $code
'
echo "== 截图与数据目录已留在 $out_dir"
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env python3
"""Build the single-file release artifacts for the current platform.
Release matrix (see `packaging/README.md` for the full contract):
Windows x64 unlucky-launcher_<ver>_windows_x64.exe (bare exe, no zip)
Windows arm64 unlucky-launcher_<ver>_windows_arm64.exe (bare exe, native arm64 runner)
Linux x64 unlucky-launcher_<ver>_linux_x64.tar.gz
Linux arm64 unlucky-launcher_<ver>_linux_arm64.tar.gz
macOS arm64 unlucky-launcher_<ver>_macos_arm64.tar.gz
macOS x64 unlucky-launcher_<ver>_macos_x64.tar.gz
every slot ull_<ver>_<platform>_<arch>[.exe] (bare CLI binary, no archive)
all SHA256SUMS.txt
all manifest.json (only with --manifest, see below)
Naming rule (user's call, 2026-11-05): `<name>_<version>_<platform>_<arch>`, underscore
separated, and **no implementation detail in the file name** — the asset never says which UI
framework produced it. `asset_stem()` is the single source of truth for that.
`--version` is taken from the cargo workspace so a tag can never drift away from
`Cargo.toml`; pass `--version` to cross-check a tag against it (mismatch is fatal).
The archives carry the launcher binary plus LICENSE / NOTICE / README.md, which is
what the license obligations ask for; Windows ships the bare `.exe` (the single-file
selling point) and the same three documents are attached to the Release next to it.
The CLI ships as its **own bare asset** on every platform rather than inside the tar.gz:
the point of `ull` is a small single file you download and run on a machine with no
display, and an archive it has to be extracted from adds a step for that user. The
launcher is the archive because it needs the license texts beside it; the Release
carries LICENSE / NOTICE / README.md anyway, so a bare `ull` loses nothing.
The launcher binary also answers every CLI subcommand, so `ull` is not a second
interface — it is the version of it that links no graphics stack at all.
`--manifest` additionally writes `manifest.json` (the update manifest, D50) into the same
output directory. The generation itself lives in `packaging/make_manifest.py`, invoked as a
subprocess: this file stays the single source of truth for **naming** (`asset_stem()`), that
one is the single writer of the **manifest**, and it derives every sha256/size from the
`SHA256SUMS.txt` this script just wrote — one set of facts, no second bookkeeping. CI does not
use this flag: `release-publish.yml` recomputes `SHA256SUMS.txt` after pruning the publish
whitelist and generates the manifest from *that*, so the manifest always describes what ships.
Slot and build directory are overridable because the build host is not always the
artifact: a cross build's binaries live in `target/<triple>/release` under an x64
host, while every native slot (all six in CI, M140) puts them in `target/release`.
CI passes both from its matrix, and the executable header is read back so a binary
can never be shipped under a slot name it was not built for.
Usage:
cargo build --release --locked
python3 packaging/make_artifacts.py [--version v0.2.0] [--out dist] [--no-smoke]
# cross build (arm64 exe from an x64 host):
python3 packaging/make_artifacts.py --slot windows-arm64 \\
--build-dir target/aarch64-pc-windows-msvc/release
"""
from __future__ import annotations
import argparse
import gzip
import hashlib
import json
import platform
import shutil
import subprocess
import sys
import tarfile
from pathlib import Path
from typing import NoReturn
# M118(D26):GUI 的**构建产物**名是 iced 版的 `unlucky-launcher-iced`。旧值 `unlucky-launcher`
# 是 `crates/app`(Slint 退役树)的 bin —— 那棵树已被根 Cargo.toml `exclude`,再也不产出它了。
# **对外**文件名仍叫 `unlucky-launcher`:下载链接 / SHA256SUMS / README 都绑着这个名字,
# 改名是「翻牌」那一步的事,不是打包这一步的事。所以刻意拆成两个常量:
# 找文件用 LAUNCHER_BIN,落盘(tar 内文件名 / exe 资产名)用 LAUNCHER_ASSET。
LAUNCHER_BIN = "unlucky-launcher-iced"
LAUNCHER_ASSET = "unlucky-launcher"
CLI_BIN = "ull"
# 随包分发的合规文本(仓库根目录);英文主版 README 是对外分发口径。
COMPLIANCE_DOCS = ("LICENSE", "NOTICE", "README.md")
SUMS_NAME = "SHA256SUMS.txt"
DEFAULT_BUILD_DIR = "target/release"
# 发布矩阵:槽位 -> (OS 家族, 架构, 产物形态)。键名即产物文件名里的槽位段,
# 单一真相源是 packaging/README.md §1;未知槽位一律拒绝,不出"凑合能跑"的产物。
SLOTS = {
"windows-x64": ("windows", "x64", "exe"),
"windows-arm64": ("windows", "arm64", "exe"),
"linux-x64": ("linux", "x64", "tar"),
"linux-arm64": ("linux", "arm64", "tar"),
"macos-arm64": ("macos", "arm64", "tar"),
"macos-x64": ("macos", "x64", "tar"),
}
# `sys.platform` / `platform.machine()` 的别名归一(aarch64 与 arm64 是同一架构的两种叫法)。
OS_FAMILIES = (("win", "windows"), ("linux", "linux"), ("darwin", "macos"))
MACHINE_ALIASES = {"amd64": "x64", "x86_64": "x64", "aarch64": "arm64", "arm64": "arm64"}
# 可执行文件头里的机器码 -> 架构(PE / ELF / Mach-O)。
IMAGE_ARCH = {
("pe", 0x8664): "x64",
("pe", 0xAA64): "arm64",
("elf", 0x3E): "x64",
("elf", 0xB7): "arm64",
("macho", 0x01000007): "x64",
("macho", 0x0100000C): "arm64",
}
REPO_ROOT = Path(__file__).resolve().parent.parent
def fail(message: str) -> NoReturn:
print(f"error: {message}", file=sys.stderr)
raise SystemExit(1)
def workspace_version() -> str:
"""Read `[workspace.package] version` through cargo itself (single source of truth)."""
try:
raw = subprocess.run(
["cargo", "metadata", "--no-deps", "--format-version", "1"],
cwd=REPO_ROOT,
check=True,
capture_output=True,
text=True,
# Windows 默认 GBK:cargo 的 package description 是中文,不显式指定会解码失败。
encoding="utf-8",
errors="replace",
).stdout
except (OSError, subprocess.CalledProcessError) as exc:
fail(f"cargo metadata failed: {exc}")
packages = json.loads(raw)["packages"]
versions = {pkg["version"] for pkg in packages}
if len(versions) != 1:
fail(f"workspace members disagree on version: {sorted(versions)}")
return versions.pop()
def host_slot(os_name: str | None = None, machine: str | None = None) -> str:
"""Map the build host to a matrix slot.
Both arguments are injectable so the mapping can be exercised for a host we do not
own (no ARM machine here); they default to the real ones.
"""
os_name = (sys.platform if os_name is None else os_name).lower()
raw_machine = platform.machine() if machine is None else machine
arch = MACHINE_ALIASES.get(raw_machine.lower())
family = next((fam for prefix, fam in OS_FAMILIES if os_name.startswith(prefix)), None)
for slot, (slot_os, slot_arch, _) in SLOTS.items():
if (slot_os, slot_arch) == (family, arch):
return slot
# 报错带**原始**取值而不是归一后的 None:`riscv64` 这类不认识的架构得在诊断里看得见。
fail(f"unsupported build host {os_name}/{raw_machine}; matrix is {' / '.join(SLOTS)}")
def binary(name: str, build_dir: Path, family: str) -> Path:
path = build_dir / (f"{name}.exe" if family == "windows" else name)
if not path.is_file():
fail(f"missing {path}; run `cargo build --release --locked` first")
return path
def image_arch(path: Path) -> str | None:
"""Architecture stamped into the executable header, or `None` if unreadable."""
with path.open("rb") as handle:
head = handle.read(0x40)
if head[:4] == b"\x7fELF":
order = "little" if head[5] == 1 else "big"
return IMAGE_ARCH.get(("elf", int.from_bytes(head[18:20], order)))
if head[:2] == b"MZ":
handle.seek(int.from_bytes(head[0x3C:0x40], "little"))
pe = handle.read(6)
if pe[:4] == b"PE\0\0":
return IMAGE_ARCH.get(("pe", int.from_bytes(pe[4:6], "little")))
return None
if head[:4] == b"\xcf\xfa\xed\xfe": # thin 64-bit little-endian Mach-O
return IMAGE_ARCH.get(("macho", int.from_bytes(head[4:8], "little")))
return None
def verify_arch(path: Path, slot: str) -> None:
"""Prove the binary really is built for the slot it is about to be named after."""
want = SLOTS[slot][1]
got = image_arch(path)
if got is None:
fail(f"cannot read the machine type of {path}; refusing to ship an unverified binary")
if got != want:
fail(f"{path} is a {got} binary but slot {slot} says {want}")
print(f"arch: {path.name} -> {got} (ok for {slot})")
def smoke_version(cli: Path, version: str) -> None:
"""`ull --version` must report the workspace version (guards tag/version drift)."""
proc = subprocess.run(
[str(cli), "--version"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
out = (proc.stdout or "") + (proc.stderr or "")
if proc.returncode != 0:
fail(f"`{cli.name} --version` exited {proc.returncode}: {out.strip()}")
if version not in out:
fail(f"`{cli.name} --version` does not report {version}: {out.strip()}")
print(f"smoke: {cli.name} --version -> {version} (ok)")
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_sums(out_dir: Path, assets: list[Path]) -> Path:
sums = out_dir / SUMS_NAME
lines = [f"{sha256(path)} {path.name}" for path in sorted(assets)]
sums.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
return sums
def write_manifest(out_dir: Path, version: str) -> Path:
"""`manifest.json`(D50):跑的是 CI 发布侧同一份脚本,事实全部来自刚写完的 SHA256SUMS.txt。"""
script = REPO_ROOT / "packaging" / "make_manifest.py"
proc = subprocess.run([sys.executable, str(script), "--dist", str(out_dir), "--version", version])
if proc.returncode != 0:
fail(f"manifest generation failed ({script.name} exited {proc.returncode})")
return out_dir / "manifest.json"
def asset_stem(name: str, version: str, slot: str) -> str:
"""`<name>_<version>_<platform>_<arch>` — 产物命名的唯一真相源。
用户令(2026-11-05):产物名按 `名字_版本号_平台_架构` 下划线分段,且**不得出现 `iced`
这类实现细节字样** —— 下载的人不该关心我们内部用哪个 UI 框架。槽位键(`windows-x64`)
仍是脚本内部的写法,落到文件名时拆成 `windows_x64` 两段。
"""
family, arch, _kind = SLOTS[slot]
return f"{name}_{version}_{family}_{arch}"
def stage_cli(out_dir: Path, version: str, slot: str, cli: Path) -> Path:
"""Copy the CLI out as its own bare asset (`ull_<ver>_<platform>_<arch>[.exe]`)."""
name = asset_stem(CLI_BIN, version, slot) + (".exe" if cli.suffix == ".exe" else "")
asset = out_dir / name
shutil.copy2(cli, asset)
# 裸文件下载即可运行:Windows 宿主机上源文件没有 x 位,这里补上(UNIX 侧才有意义)。
asset.chmod(0o755)
return asset
def stage_archive(out_dir: Path, tag: str, version: str, launcher: Path) -> Path:
"""tar.gz with one top-level dir holding the binary + compliance docs."""
stem = asset_stem(LAUNCHER_ASSET, version, tag)
stage = out_dir / stem
if stage.exists():
shutil.rmtree(stage)
stage.mkdir(parents=True)
shutil.copy2(launcher, stage / LAUNCHER_ASSET)
for doc in COMPLIANCE_DOCS:
source = REPO_ROOT / doc
if not source.is_file():
fail(f"compliance file {source} is missing")
shutil.copy2(source, stage / doc)
# tar 内的 mtime/uid/mode 与 gzip 头的 mtime 全部归零 → 同源构建的 sha256 与打包时刻无关。
archive = out_dir / f"{stem}.tar.gz"
with archive.open("wb") as raw, gzip.GzipFile(
fileobj=raw, mode="wb", compresslevel=9, mtime=0
) as gz, tarfile.open(fileobj=gz, mode="w") as tar:
for entry in sorted(stage.iterdir()):
info = tar.gettarinfo(str(entry), arcname=f"{stem}/{entry.name}")
info.mtime = 0
info.uid = info.gid = 0
info.uname = info.gname = ""
info.mode = 0o755 if entry.name == LAUNCHER_ASSET else 0o644
with entry.open("rb") as handle:
tar.addfile(info, handle)
shutil.rmtree(stage)
return archive
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", help="expected version (a leading 'v' is stripped); cross-checks Cargo.toml")
parser.add_argument("--out", default="dist", help="output directory (default: dist/)")
parser.add_argument("--slot", choices=list(SLOTS), help="matrix slot; defaults to the one derived from the host")
parser.add_argument("--build-dir", default=DEFAULT_BUILD_DIR, help=f"directory holding the built binaries (default: {DEFAULT_BUILD_DIR})")
parser.add_argument("--no-smoke", action="store_true", help="skip the `ull --version` smoke run")
parser.add_argument("--manifest", action="store_true", help="also write manifest.json (update manifest, D50)")
args = parser.parse_args()
version = workspace_version()
if args.version:
expected = args.version[1:] if args.version.startswith("v") else args.version
if expected != version:
fail(f"tag/version mismatch: requested {expected}, Cargo.toml says {version}")
native = host_slot()
slot = args.slot or native
family, _, kind = SLOTS[slot]
build_dir = Path(args.build_dir)
if not build_dir.is_absolute():
build_dir = REPO_ROOT / build_dir
launcher = binary(LAUNCHER_BIN, build_dir, family)
verify_arch(launcher, slot)
# 跨槽位产物(宿主 x64 / 目标 arm64)在本机根本执行不了,冒烟只能等 CI 真机;
# 拿"跳过了"当通过是假证据,所以把跳过原因原样打出来。
if args.no_smoke or slot != native:
reason = "--no-smoke" if args.no_smoke else f"host is {native}, artifact is {slot}"
print(f"smoke: skipped ({reason})")
else:
smoke_version(binary(CLI_BIN, build_dir, family), version)
out_dir = Path(args.out)
if not out_dir.is_absolute():
out_dir = REPO_ROOT / out_dir
out_dir.mkdir(parents=True, exist_ok=True)
if kind == "exe":
asset = out_dir / f"{asset_stem(LAUNCHER_ASSET, version, slot)}.exe"
shutil.copy2(launcher, asset)
else:
asset = stage_archive(out_dir, slot, version, launcher)
# CLI 与 GUI 同一矩阵槽位、各自独立资产:架构同样回读文件头校验,不给 arm64 槽位混进 x64 的 ull。
cli = binary(CLI_BIN, build_dir, family)
verify_arch(cli, slot)
cli_asset = stage_cli(out_dir, version, slot, cli)
sums = write_sums(out_dir, [asset, cli_asset])
for item in (asset, cli_asset):
size_mb = item.stat().st_size / (1024 * 1024)
print(f"artifact: {item.name} {size_mb:.2f} MiB sha256={sha256(item)}")
print(f"sums: {sums.name}")
if args.manifest:
manifest = write_manifest(out_dir, version)
print(f"manifest: {manifest.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+396
View File
@@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""Generate `manifest.json` — the update manifest the launcher reads (D50).
Contract and rationale live in `packaging/README.md` §13; this docstring only states what the
code must not get wrong.
* **Shape authority** = `crates/core/src/update/manifest.rs` (`ReleaseManifest`,
`deny_unknown_fields` + per-field validation). This script only writes that shape.
* **Facts come from one place**: every `sha256` is read out of the Release's own
`SHA256SUMS.txt`, every `size` is `stat()` of the same file — the two can never disagree.
The version token inside each asset name is asserted to equal `--version`, so a tag that
drifted from `Cargo.toml` cannot yield a manifest pointing at files that do not exist.
* **Only launcher artifacts go in.** `ull` does not: the DTO has no role field and
`Release::artifact_for(target)` returns the *first* match, so listing the CLI beside the GUI
would make "which file do I overwrite" depend on array order.
* **`url`**: bare asset name by default (the `DirSource` convention in
`crates/core/src/update/source.rs`); with `--asset-base-url` a tag-pinned absolute URL,
which is what CI uses.
Usage:
python3 packaging/make_manifest.py --dist dist --version v0.2.2 --channel stable \\
--asset-base-url https://github.com/o/r/releases/download/v0.2.2
python3 packaging/make_manifest.py --self-test # golden + strict-parse self check
"""
from __future__ import annotations
import argparse
import copy
import json
import re
import sys
from pathlib import Path
from typing import NoReturn
# 本机 Python 可能是 isolated 模式(`safe_path=True`,脚本目录不进 `sys.path`),CI 的标准
# python3 则会自动加入。显式补一次,两种环境下 `import make_artifacts` 都成立。
sys.path.insert(0, str(Path(__file__).resolve().parent))
# 只借常量:产物命名(SLOTS / LAUNCHER_ASSET)、CLI 名与 SHA256SUMS 文件名都是 make_artifacts
# 的单一口径,这里不复制一份表(复制就会漂);反向依赖不存在(make_artifacts 只用子进程调本脚本)。
import make_artifacts
MANIFEST_NAME = "manifest.json"
CHANNELS = ("stable", "beta", "alpha", "nightly")
# 文件名里的平台段 -> 清单 target 的系统段(`windows_x64` -> `win-x64`)。
PLATFORM_TARGET = {"windows": "win", "macos": "mac", "linux": "linux"}
# 产物扩展名 -> 清单 format。归档(tar.gz)在 DTO 里只有「解压即用」这一档 `portable-zip`:
# 名字带 zip,语义是「非安装器、可原地替换」(PackageFormat::needs_installer() == false)。
EXT_FORMAT = {".exe": "portable-exe", ".tar.gz": "portable-zip"}
GOLDEN = Path(__file__).resolve().parent / "tests" / "manifest.golden.json"
# DTO 侧的取值表镜像(crates/core/src/update/target.rs::parse_os / Arch::parse / PackageFormat::parse)。
TARGET_OS = {"win", "windows", "mac", "macos", "osx", "darwin", "linux"}
TARGET_ARCH = {
"x64", "x86_64", "x86-64", "amd64", "arm64", "aarch64", "arm64e", "x86", "i686", "i386", "x86_32",
}
FORMATS = {"msi", "dmg", "appimage", "deb", "rpm", "portable-zip", "zip", "portable-exe", "exe"}
SEMVER = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$")
SUMS_LINE = re.compile(r"^([0-9A-Fa-f]{64})[ \t]+\*?(.+)$")
def fail(message: str) -> NoReturn:
print(f"error: {message}", file=sys.stderr)
raise SystemExit(1)
def normalize_version(text: str) -> str:
"""`v0.2.2` -> `0.2.2`(tag 带 `v`,产物名与清单都不带)。"""
version = text.strip()
version = version[1:] if version.startswith("v") else version
if not semver_ok(version):
fail(f"version `{text}` is not a semver")
return version
def semver_ok(text: str) -> bool:
"""Mirror of `crates/core/src/update/version.rs::Version::parse` on the accepted shapes."""
match = SEMVER.match(text)
if not match:
return False
if any(len(number) > 1 and number.startswith("0") for number in match.group(1, 2, 3)):
return False
pre, build = match.group(4), match.group(5)
if pre is not None:
for ident in pre.split("."):
if not ident or (ident.isdigit() and len(ident) > 1 and ident.startswith("0")):
return False
if build is not None and any(not ident for ident in build.split(".")):
return False
return True
def read_sums(dist: Path) -> dict[str, str]:
"""`SHA256SUMS.txt` -> {file name: lowercase sha256}. Missing/odd entries are fatal."""
sums_path = dist / make_artifacts.SUMS_NAME
if not sums_path.is_file():
fail(f"missing {sums_path}(清单的 sha256 只从这里取,不另算一份)")
sums: dict[str, str] = {}
for lineno, line in enumerate(sums_path.read_text(encoding="utf-8").splitlines(), 1):
if not line.strip():
continue
match = SUMS_LINE.match(line)
if not match:
fail(f"{sums_path.name}:{lineno} 不是 `<sha256> <文件名>` 形态:{line!r}")
digest, name = match.group(1).lower(), match.group(2)
if name in sums:
fail(f"{sums_path.name} 里 {name} 出现两次(无法确定哪个 sha256 是对的)")
sums[name] = digest
if not sums:
fail(f"{sums_path} 是空的:没有产物就没有清单")
return sums
def split_asset(name: str, version: str) -> tuple[str, str, str, str]:
"""`<name>_<version>_<platform>_<arch>[.exe|.tar.gz]` -> (name, platform, arch, ext)."""
stem, ext = name, ""
for candidate in EXT_FORMAT:
if stem.endswith(candidate):
stem, ext = stem[: -len(candidate)], candidate
break
parts = stem.rsplit("_", 3)
if len(parts) != 4:
fail(f"`{name}` 不符合 `<名字>_<版本>_<平台>_<架构>` 命名(见 make_artifacts.asset_stem)")
product, tag_version, platform_name, arch_name = parts
if tag_version != version:
fail(f"`{name}` 里的版本是 {tag_version},清单要的是 {version}(tag 与 Cargo.toml 漂了?)")
if (platform_name, arch_name) not in {
(slot_os, slot_arch) for slot_os, slot_arch, _kind in make_artifacts.SLOTS.values()
}:
fail(f"`{name}` 的平台/架构 {platform_name}/{arch_name} 不在发布矩阵里")
return product, platform_name, arch_name, ext
def build_artifacts(dist: Path, version: str, base_url: str) -> list[dict[str, object]]:
"""Launcher artifacts from `SHA256SUMS.txt`, in matrix order (= priority order)."""
sums = read_sums(dist)
by_slot: dict[tuple[str, str], dict[str, object]] = {}
for name, digest in sums.items():
product, platform_name, arch_name, ext = split_asset(name, version)
if product != make_artifacts.LAUNCHER_ASSET:
continue # `ull` 不进清单,理由见模块注释
fmt = EXT_FORMAT.get(ext)
if fmt is None:
fail(f"`{name}` 没有可识别的扩展名,无法判定包形态(format)")
path = dist / name
if not path.is_file():
fail(f"{make_artifacts.SUMS_NAME} 提到 {name},但 {path} 不存在")
size = path.stat().st_size
if size == 0:
fail(f"{path} 是 0 字节:这类产物只可能是打包漏了")
url = f"{base_url.rstrip('/')}/{name}" if base_url else name
if (platform_name, arch_name) in by_slot:
fail(f"{platform_name}/{arch_name} 出现两个启动器产物,清单无法确定该用哪一个")
by_slot[(platform_name, arch_name)] = {
"target": f"{PLATFORM_TARGET[platform_name]}-{arch_name}",
"format": fmt,
"url": url,
"size": size,
"sha256": digest,
}
order = [(slot_os, slot_arch) for slot_os, slot_arch, _kind in make_artifacts.SLOTS.values()]
artifacts_out = [by_slot[key] for key in order if key in by_slot]
if not artifacts_out:
fail(f"{make_artifacts.SUMS_NAME} 里没有任何 `{make_artifacts.LAUNCHER_ASSET}_*` 产物,清单会是空的")
return artifacts_out
def build_manifest(
dist: Path,
version: str,
channel: str,
*,
base_url: str = "",
min_supported: str | None = None,
notes: str | None = None,
released_at: str | None = None,
force: bool = False,
) -> dict[str, object]:
"""Assemble the manifest document (the shape `ReleaseManifest` deserializes)."""
if channel not in CHANNELS:
fail(f"未知渠道 `{channel}`(只认 {' / '.join(CHANNELS)})")
release: dict[str, object] = {"version": version, "channel": channel}
if released_at:
release["released_at"] = released_at
if notes:
release["notes"] = notes
release["force"] = force
if min_supported:
release["min_supported"] = normalize_version(min_supported)
release["artifacts"] = build_artifacts(dist, version, base_url)
return {"channel": channel, "releases": [release]}
def render(doc: dict[str, object]) -> str:
"""Deterministic JSON: same facts + same args ⇒ byte-identical file (golden-testable)."""
return json.dumps(doc, ensure_ascii=False, indent=2) + "\n"
# ── 严格解析镜像(DTO 的看门规则)───────────────────────────────────────────────────────
# `manifest.rs` 用 `deny_unknown_fields` + 逐字段校验;这里是它的 Python 镜像,只为了在没有
# Rust 的机器上(本地打包、CI 发布步)也能当场炸掉拼错的字段。字段/取值的权威永远是 .rs。
def check(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def check_keys(where: str, obj: object, allowed: set[str], required: set[str]) -> dict:
check(isinstance(obj, dict), f"{where} 必须是对象")
unknown = set(obj) - allowed
check(not unknown, f"{where} 出现未知字段 {sorted(unknown)}(DTO 是 deny_unknown_fields)")
missing = required - set(obj)
check(not missing, f"{where} 缺字段 {sorted(missing)}")
return obj
def strict_parse(doc: object) -> None:
"""Raise `ValueError` on anything `ReleaseManifest::from_json` would reject."""
top = check_keys("清单", doc, {"channel", "releases"}, {"channel"})
check(top["channel"] in CHANNELS, f"清单渠道 `{top['channel']}` 无法识别")
releases = top.get("releases", [])
check(isinstance(releases, list), "releases 必须是数组")
seen: list[str] = []
for index, raw in enumerate(releases):
where = f"releases[{index}]"
release = check_keys(
where,
raw,
{"version", "channel", "released_at", "notes", "force", "min_supported", "artifacts"},
{"version"},
)
version = release["version"]
check(isinstance(version, str) and semver_ok(version), f"{where}.version 不是合法 semver")
check(version not in seen, f"{where}.version {version} 在清单里重复出现")
seen.append(version)
if "channel" in release:
check(release["channel"] in CHANNELS, f"{where}.channel 无法识别")
for key in ("released_at", "notes"):
check(release.get(key) is None or isinstance(release.get(key), str), f"{where}.{key} 必须是字符串")
check(isinstance(release.get("force", False), bool), f"{where}.force 必须是布尔")
if release.get("min_supported") is not None:
check(semver_ok(str(release["min_supported"])), f"{where}.min_supported 不是合法 semver")
artifacts_list = release.get("artifacts", [])
check(isinstance(artifacts_list, list), f"{where}.artifacts 必须是数组")
for art_index, raw_art in enumerate(artifacts_list):
art_where = f"{where}.artifacts[{art_index}]"
art = check_keys(
art_where,
raw_art,
{"target", "format", "url", "size", "sha256", "sha1"},
{"target", "format", "url", "size"},
)
os_part, _, arch_part = str(art["target"]).strip().partition("-")
check(
bool(arch_part) and os_part.lower() in TARGET_OS and arch_part.lower() in TARGET_ARCH,
f"{art_where}.target `{art['target']}` 无法识别(形如 win-x64)",
)
check(str(art["format"]).lower() in FORMATS, f"{art_where}.format 无法识别")
check(isinstance(art["url"], str) and art["url"].strip(), f"{art_where}.url 为空")
size = art["size"]
check(isinstance(size, int) and not isinstance(size, bool) and size > 0, f"{art_where}.size 必须 > 0")
sha256, sha1 = art.get("sha256"), art.get("sha1")
check(sha256 is not None or sha1 is not None, f"{art_where} 既没有 sha256 也没有 sha1")
digest_hex = str(sha256 if sha256 is not None else sha1)
expect = 64 if sha256 is not None else 40
check(
len(digest_hex) == expect and all(c in "0123456789abcdefABCDEF" for c in digest_hex),
f"{art_where} 的摘要应为 {expect} 位十六进制",
)
def self_test() -> int:
"""Golden 对拍(`.gitattributes` 的 `eol=lf` 保证跨平台同字节)+ 严格解析正负例:证明写出来的东西能被 DTO 吃下。"""
import tempfile
with tempfile.TemporaryDirectory() as tmp:
dist = Path(tmp)
version = "9.9.9"
# 两种启动器形态(裸 exe / 归档)+ 一个 CLI 资产:既覆盖 target/format 映射,
# 也钉住「`ull` 不进清单」这条决定。
payloads = {
f"{make_artifacts.LAUNCHER_ASSET}_{version}_windows_x64.exe": b"gui payload\n",
f"{make_artifacts.LAUNCHER_ASSET}_{version}_linux_x64.tar.gz": b"tar payload\n",
f"{make_artifacts.CLI_BIN}_{version}_windows_x64.exe": b"cli payload\n",
}
for name, payload in payloads.items():
(dist / name).write_bytes(payload)
(dist / make_artifacts.SUMS_NAME).write_text(
"".join(f"{make_artifacts.sha256(dist / name)} {name}\n" for name in payloads),
encoding="utf-8",
newline="\n",
)
doc = build_manifest(
dist,
version,
"stable",
min_supported="9.0.0",
notes="golden 样例(自验用,不是真实发布)",
released_at="2026-01-02T03:04:05Z",
)
text = render(doc)
if "--write-golden" in sys.argv:
GOLDEN.parent.mkdir(parents=True, exist_ok=True)
GOLDEN.write_text(text, encoding="utf-8", newline="\n")
print(f"golden: {GOLDEN} 已重写")
expected = GOLDEN.read_text(encoding="utf-8")
if text != expected:
print("--- generated ---", text, "--- golden ---", expected, sep="\n", file=sys.stderr)
fail("生成的清单与 golden 不一致(改了口径就 --write-golden 重出并对 diff)")
print(f"golden: 与 {GOLDEN.name} 逐字节一致")
strict_parse(doc)
print("strict: 生成物通过严格解析镜像(deny_unknown_fields + 逐字段校验)")
# 负例:只测「顺利通过」等于没测——每条篡改都必须被拒。
for label, mutate in (
("未知字段", lambda d: d.update(extra=1)),
("未知版本字段", lambda d: d["releases"][0].update(extra=1)),
("未知产物字段", lambda d: d["releases"][0]["artifacts"][0].update(extra=1)),
("缺 sha256", lambda d: d["releases"][0]["artifacts"][0].pop("sha256")),
("摘要长度错", lambda d: d["releases"][0]["artifacts"][0].update(sha256="deadbeef")),
("size 为 0", lambda d: d["releases"][0]["artifacts"][0].update(size=0)),
("size 是字符串", lambda d: d["releases"][0]["artifacts"][0].update(size="12")),
("未知 target", lambda d: d["releases"][0]["artifacts"][0].update(target="solaris-x64")),
("未知 format", lambda d: d["releases"][0]["artifacts"][0].update(format="msix")),
("空 url", lambda d: d["releases"][0]["artifacts"][0].update(url=" ")),
("版本重复", lambda d: d["releases"].append(copy.deepcopy(d["releases"][0]))),
("版本号不合法", lambda d: d["releases"][0].update(version="1.0")),
("渠道不认识", lambda d: d.update(channel="canary")),
("缺顶层 channel", lambda d: d.pop("channel")),
):
tampered = copy.deepcopy(doc)
mutate(tampered)
try:
strict_parse(tampered)
except ValueError:
print(f"strict: 负例「{label}」被正确拒绝")
continue
fail(f"严格解析放过了负例「{label}」——这条校验形同虚设")
print("self-test: ok")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--dist", default="dist", help="directory holding the assets + SHA256SUMS.txt (default: dist/)")
parser.add_argument("--version", help="release version, e.g. v0.2.2 (must match the asset file names)")
parser.add_argument("--channel", default="stable", choices=CHANNELS, help="manifest channel (D50: stable only for now)")
parser.add_argument("--out", help=f"output path (default: <dist>/{MANIFEST_NAME})")
parser.add_argument("--asset-base-url", default="", help="prefix for artifact urls; omit for bare file names")
parser.add_argument("--min-supported", help="lowest version still supported (omitted when not given)")
parser.add_argument("--notes", help="plain-text release notes for the update prompt")
parser.add_argument("--notes-file", help="read --notes from this file")
parser.add_argument("--released-at", help="RFC3339 timestamp (display only)")
parser.add_argument("--force", action="store_true", help="mark this release as force-update")
parser.add_argument("--self-test", action="store_true", help="golden + strict-parse self check (no files written)")
parser.add_argument("--write-golden", action="store_true", help=argparse.SUPPRESS)
args = parser.parse_args()
if args.self_test:
return self_test()
if not args.version:
parser.error("--version is required (it must match the asset file names)")
dist = Path(args.dist)
if not dist.is_absolute():
dist = make_artifacts.REPO_ROOT / dist
notes = args.notes
if args.notes_file:
notes = (make_artifacts.REPO_ROOT / args.notes_file).read_text(encoding="utf-8").strip()
doc = build_manifest(
dist,
normalize_version(args.version),
args.channel,
base_url=args.asset_base_url,
min_supported=args.min_supported,
notes=notes,
released_at=args.released_at,
force=args.force,
)
text = render(doc)
strict_parse(doc)
out = Path(args.out) if args.out else dist / MANIFEST_NAME
if not out.is_absolute():
out = make_artifacts.REPO_ROOT / out
out.write_text(text, encoding="utf-8", newline="\n")
release = doc["releases"][0]
artifacts_list = release["artifacts"]
print(f"manifest: {out.name} channel={doc['channel']} version={release['version']} artifacts={len(artifacts_list)}")
for item in artifacts_list:
print(f" {item['target']:<14} {item['format']:<13} {item['size']:>10} B sha256={str(item['sha256'])[:12]} {item['url']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+101
View File
@@ -0,0 +1,101 @@
// PE 版本资源(VERSIONINFO)的唯一口径:发布者/产品/版权/版本号。
//
// 这个文件被 `crates/cli/build.rs` 与 `crates/app-iced/build.rs` 各 `include!` 一次——两个产物
// (`ull` 与 `unlucky-launcher-iced`)的署名必须逐字相同,复制两份表迟早会漂。
//
// # 字段口径(M190,塔已拍板)
//
// | 字段 | 值 | 为什么 |
// |---|---|---|
// | `CompanyName` | `Unlucky Launcher Project` | 发布者。**不得出现个人真实姓名**:这个名字随每个下载的 exe 传播,将来买 Authenticode 证书时 SmartScreen 显示的是证书主体,二者要对得上 |
// | `ProductName` / `FileDescription` | `Unlucky Launcher` | Windows「属性 → 详细信息」里最显眼的两行 |
// | `LegalCopyright` | `© 2026 Unlucky Launcher Project · Apache-2.0` | 版权 + 许可(仓库是 Apache-2.0) |
// | `FileVersion` / `ProductVersion` | `CARGO_PKG_VERSION` 的四段式 | 与 `Cargo.toml` **同源**:不另写一份版本号 |
// | `InternalName` / `OriginalFilename` | bin 名 / `<bin>.exe` | 供「这个 exe 到底叫什么」的取证 |
// | `Comments` | 仓库地址 | 项目署名 `AskaEth` 的可读落点(GitHub 命名空间,不是个人姓名) |
//
// # 只在 windows 目标上真跑
//
// 版本资源是 PE 概念:`build.rs` 先看 `CARGO_CFG_TARGET_OS`,不是 `windows` 就整段返回,
// Linux/macOS 构建一个字节都不多编译(它们的发布产物本来也没有 PE 头)。
//
// # 为什么不用 `windows_subsystem` 之外的资源
//
// 只要 VERSIONINFO 一块:图标、清单(DPI/权限)另有归属,本 mission 不碰(加了会改变运行时行为)。
//
// # 守门
//
// `packaging/check_pe_version.py` 是「写进源码不算,读回产物才算」的那道判据:它直接读 exe 字节,
// 断言 `VS_VERSION_INFO` 结构在、上述字符串(UTF-16LE)在。CI 的构建槽位跑它。
/// 发布者(CompanyName)。**改它等于改署名**:同时要改 Authenticode 证书主体与 README 的署名口径。
pub const COMPANY_NAME: &str = "Unlucky Launcher Project";
/// 产品名(`ProductName` 与 `FileDescription`)。
pub const PRODUCT_NAME: &str = "Unlucky Launcher";
/// 版权行(`LegalCopyright`)。
pub const COPYRIGHT: &str = "© 2026 Unlucky Launcher Project · Apache-2.0";
/// 项目署名(GitHub 命名空间;`Comments` 里随仓库地址一起出现)。
pub const PROJECT_HANDLE: &str = "AskaEth";
/// 仓库地址(`Comments`)。
pub const REPOSITORY: &str = "https://github.com/AskaEth/Unlucky-Launcher";
/// 把版本号补成四段式的数值(`0.2.3` → `0.2.3.0`;预发布段丢掉,因为 PE 的数值字段只认数字)。
///
/// 返回 `(四段元组, 打包成 FILEVERSION 用的 u64)`;任一数字大于 65535 时按 65535 截断
/// (PE 的每个分量只有 16 位,真实版本号远小于它)。
#[must_use]
pub fn four_part(version: &str) -> ([u64; 4], u64) {
// `0.2.3-beta.1` 的数值部分只有前三段;`+build` 元数据同理不计入版本资源。
let numeric = version.split(['-', '+']).next().unwrap_or(version).trim();
let mut parts = [0_u64; 4];
for (index, piece) in numeric.split('.').take(4).enumerate() {
parts[index] = piece.parse::<u64>().unwrap_or(0).min(0xFFFF);
}
let packed = (parts[0] << 48) | (parts[1] << 32) | (parts[2] << 16) | parts[3];
(parts, packed)
}
/// 给一个 bin 名(`ull` / `unlucky-launcher-iced`)写出版本资源。
///
/// 由两个 crate 的 `build.rs` 调用;非 windows 目标直接返回。
pub fn emit_pe_version_resource(binary_name: &str) {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION");
// 共享口径文件改了也要重编(`include!` 不被 cargo 追踪)。
println!("cargo:rerun-if-changed=../../packaging/pe_version.rs");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
// Linux/macOS:没有 PE 头,整段不跑(发布产物的形状不变)。
return;
}
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string());
let (parts, packed) = four_part(&version);
let dotted = format!("{}.{}.{}.{}", parts[0], parts[1], parts[2], parts[3]);
let mut resource = winresource::WindowsResource::new();
resource.set("CompanyName", COMPANY_NAME);
resource.set("ProductName", PRODUCT_NAME);
resource.set("FileDescription", PRODUCT_NAME);
resource.set("LegalCopyright", COPYRIGHT);
resource.set("InternalName", binary_name);
resource.set("OriginalFilename", &format!("{binary_name}.exe"));
resource.set("Comments", REPOSITORY);
// 字符串形态(「属性」页显示的)与数值形态(资源管理器/安装器排序用的)都要给。
resource.set("FileVersion", &dotted);
resource.set("ProductVersion", &dotted);
resource.set_version_info(winresource::VersionInfo::FILEVERSION, packed);
resource.set_version_info(winresource::VersionInfo::PRODUCTVERSION, packed);
if let Err(error) = resource.compile() {
// 资源编不出来就**中断构建**:宁可不出产物,也不出一个「属性里什么都没有」的 exe
// (那正是 M190 要修的病)。
panic!("写 PE 版本资源失败({binary_name} {dotted}):{error}");
}
println!("cargo:warning=PE VERSIONINFO: {binary_name} {dotted} / {COMPANY_NAME}");
let _ = PROJECT_HANDLE;
}
+210
View File
@@ -0,0 +1,210 @@
# Unlucky Launcher {{VERSION}}
Rust-native **single-file** Minecraft launcher: iced 0.14 + Material 3 Expressive UI, built-in AI agent
(chat / setting edits / crash diagnosis) and an MCP server other agent tools can attach to.
No WebView, no bundled runtime, no JRE download needed to start the launcher itself.
## What's new in {{VERSION}}
- **Linux x64 is a published platform.** The release workflow now builds `linux-x64` on a native `ubuntu-22.04`
runner by default (not only in manual all-slots runs) and publishes it: `unlucky-launcher_{{VERSION}}_linux_x64.tar.gz`
plus the bare `ull_{{VERSION}}_linux_x64`. The artifact itself was started on a Linux machine under a real display
server, rendered a frame and logged the expected theme/backend lines before this decision, and the same check is
now a per-slot CI gate. Linux ARM64 is built too, but stays unpublished until an ARM64 machine runs it.
- **{{VERSION}} is the first release published as a stable release.** GitHub only points
`releases/latest` at the newest **non-prerelease** release, so `0.2.2` — kept as a historical
pre-release, untouched — never appeared there. A pushed tag now creates a stable release by default;
a pre-release has to be asked for explicitly (`workflow_dispatch` with `prerelease=true`).
- **Self-update works end to end.** `ull update check` / `ull update apply` and the settings panel's
Update group share one core (`crates/core/src/update/`): manifest, version and channel admission,
length + SHA256 verification, and an atomic rename-based swap that rolls back on failure. Only the
stable channel is published today.
- **Online content library (Modrinth, anonymous).** Search, project details and version lists come from
Modrinth's v2 API with facets derived from the target instance, and a known hash completes a remote
entry. Being offline is reported as "not connected", never as "no matching content".
- **Per-file decisions when importing a modpack.** The pre-import scan lists every conflicting file in
pages of 40, and you choose keep-all, overwrite-all, or per item; files that do not conflict are
never rewritten, and the report names every conflict afterwards.
- **Per-instance launch arguments.** JVM arguments, game arguments, memory, the Java executable and the
graphics backend (`default` / `opengl` / `vulkan`) are set per instance, each field falling back on
its own to the global setting and then the factory default. The CLI reads the same resolved values.
## Downloads
Asset names are `<name>_<version>_<platform>_<arch>`, underscore separated — the rule
`packaging/make_artifacts.py::asset_stem()` applies:
| Platform | Asset | Notes |
| --- | --- | --- |
| Windows 10/11 x64 | `unlucky-launcher_{{VERSION}}_windows_x64.exe` | single exe, double-click to run; this is also what you run on ARM64 Windows (x64 emulation) |
| Linux x64 | `unlucky-launcher_{{VERSION}}_linux_x64.tar.gz` | one archive, one top-level directory holding the binary plus `LICENSE` / `NOTICE` / `README.md`; needs glibc 2.35+ and the usual X11/Wayland + fontconfig stack |
**Windows ARM64 is not part of this release.** CI still builds it on a native `windows-11-arm` runner (Windows 11
Arm64 with Visual Studio 2026), but the build has not delivered an artifact yet, so
`unlucky-launcher_{{VERSION}}_windows_arm64.exe` is deliberately kept out of the download list and out of
`SHA256SUMS.txt` instead of handing out a build nobody has verified. Worth stating plainly: every ARM64 failure
so far happened in a **pre-build toolchain probe** that guessed whether the SDK's ARM64 import libraries were
present and aborted before `cargo build` ever ran — the compiler and linker have not been heard from yet. That
probe is now evidence-only (it prints what it sees and never decides the job), so the next CI run is the first
one that can actually answer the question. ARM64 rejoins this table as soon as a run produces its artifact (one
line in the workflow's `PUBLISH_SLOTS`, guarded on both sides by `packaging/check_publish_slots.py`).
**Linux ARM64 is built but not published, and it has no runtime evidence yet.** `linux-arm64` joined the default
build matrix in this release (native `ubuntu-22.04-arm`, same glibc 2.35 floor as x64), so the artifact exists as
build evidence, but nothing has ever run it on ARM64 hardware — not one frame. It stays out of the download list
and out of `SHA256SUMS.txt` until an ARM64 machine renders a frame and the theme/backend log lines come out as
expected; that run is the single unlock condition, and copying a build nobody executed is exactly what this list
is meant to prevent. The publish job prunes that artifact with an explicit "not published" warning rather than
dropping it quietly.
**macOS is not built by the release workflow at all.** The Apple Silicon and Intel slots never got past a manual
probe, so no build minutes are spent on them and nothing unverified is handed out. They stay out of the download
list and out of `SHA256SUMS.txt`; a manual workflow run can build all six slots for evidence, but the probe slots
are never published. The support matrix below marks those slots accordingly.
The Windows asset is a bare `.exe`, so `LICENSE`, `NOTICE` and `README.md` are attached to this release
next to it: the license terms travel with every download. The Linux archive unpacks to one top-level directory
of the same name holding the binary plus those three documents.
## Command line
Each published slot also ships the command line as its own small bare asset,
`ull_{{VERSION}}_<platform>_<arch>` (`.exe` on Windows):
```powershell
.\ull_{{VERSION}}_windows_x64.exe instances list # servers and CI: no graphics stack needed
```
```bash
./ull_{{VERSION}}_linux_x64 instances list # same binary shape on Linux, no display needed
```
`ull` runs `instances`, `install`, `pack import`, `launch`, `accounts`, `sessions`, `logs`, `diag`,
`config`, `java` and `agent`, and links no graphics library at all. The GUI binary accepts the same set
before it ever opens a window, so a desktop machine does not need `ull`.
## Updating
The launcher reads `releases/latest/download/manifest.json` on startup (switchable: `update.auto_check`)
and shows four distinct outcomes: up to date, an update is available, a newer version exists but ships no
artifact for this platform, or the check itself failed. `ull update check` prints the same verdict, and
`ull update apply` downloads, verifies SHA256 and swaps the binary atomically, rolling back if the swap
fails. Nothing installs without you asking for it.
## Support matrix
Published = shipped as a download of **this** release, listed in `SHA256SUMS.txt`.
| Platform | Status in this release | Level | Runtime requirements |
| --- | --- | --- | --- |
| Windows 10/11 x64 | **published** | first class | nothing beyond the OS (UCRT is part of Win10+) |
| Linux x64, X11 | **published** | first class | glibc 2.35+ (`libxkbcommon`, `fontconfig`, `libGL` or the software renderer, `libxcb`) |
| Linux x64, Wayland | **published** (same archive, Wayland path unverified) | supported | the same archive; `wayland-client` + `libxkbcommon`, plus the X11 set for XWayland apps |
| Windows 11 ARM64 | not published (build evidence only) | first class (launcher artifact) | nothing extra; the launcher runs natively, the game itself runs through the OS x64 emulation layer with an x64 JDK |
| Linux arm64 | built, not published (no runtime evidence) | first class (unverified) | glibc 2.35+ (native `ubuntu-22.04-arm`, same floor as x64), `libxkbcommon`, `fontconfig`, `libGL`, `libxcb` / `wayland-client` |
| macOS 12+ Apple Silicon | not built (unverified) | first class (unverified) | nothing; unsigned build, needs the Gatekeeper workaround |
| macOS 12+ Intel | not built (unverified) | first class (unverified) | same as above |
| Windows 8.1/8/7 | not built | unsupported | the baseline is Windows 10 |
| Android — Termux + X11 | not built (no CI slot) | experimental, not promised | CLI and GUI from source only; GUI verified on-device twice: CPU software rendering and self-built Mesa/turnip GPU acceleration (see `packaging/docs/termux-glibc-build.md` and `README.termux.md`) |
Two slots are published this release: `windows-x64` and `linux-x64`. Linux x64 is not a claim taken on faith — the
release artifact itself was started on a Linux machine, opened a real window under a real display server, rendered a
frame (PNG asserted non-blank) and printed the expected `[theme]` and `[backend] final=… result=started` lines, and
the release build now carries the same check as a per-slot CI gate (`packaging/docs/linux-evidence.md` has the raw
output). What that does **not** cover: dragging/resizing and font metrics / CJK shaping on a real desktop session.
Whole-window transparency and rounded corners have no code path on Linux at all (transparency needs a native window
handle the app does not get there; corners are delegated to the desktop compositor), so they are not "unverified" —
they are not applicable. Nothing at all is known about Linux ARM64: no ARM64 machine has run the binary yet, which is
why that row stays unpublished.
Both arm64 rows are formal matrix slots, not the experimental Termux path, and Linux arm64 is not published until
someone runs it on real hardware — a manual all-slots run builds it today, and one real frame on an ARM64 machine is
the unlock. The rows above and the matrix table in `packaging/docs/compliance-support-boundaries.md` §9 are the same
list — change one, change the other.
## Install
**Windows** — put the `.exe` anywhere and run it. `_windows_x64.exe` is the only Windows asset in this
release, and it is also what you run on ARM64 Windows (the OS runs it through its x64 emulation layer).
The first run creates `.ULL/` and `.minecraft/` next to the exe (portable is the default data mode); the
per-user `%APPDATA%\ULL` plus `%APPDATA%\.minecraft`, or a custom root, are selectable instead.
**Linux x64** — unpack the archive and run the binary inside it; nothing is installed system-wide:
```bash
tar -xzf unlucky-launcher_{{VERSION}}_linux_x64.tar.gz
cd unlucky-launcher_{{VERSION}}_linux_x64
./unlucky-launcher # X11 or Wayland; --software-renderer forces the CPU rasteriser
```
It needs glibc 2.35+ (the slot is built on Ubuntu 22.04, so that is the floor), `libxkbcommon`, `fontconfig`
and `libGL` — or `--software-renderer` if there is no usable GPU adapter. On Debian/Ubuntu:
```bash
sudo apt-get install libxkbcommon0 libfontconfig1 libgl1 libx11-6 libxcursor1 libxrandr2 libxi6
```
The bare `ull_{{VERSION}}_linux_x64` next to the archive runs on a machine with no display at all.
Linux arm64 and macOS are not part of this release; their asset names are already fixed by the same rule
(`unlucky-launcher_{{VERSION}}_linux_arm64.tar.gz`, `_macos_arm64`, `_macos_x64`) so the links will not change
shape when those slots open.
## No GPU? Software renderer
The UI renders through `wgpu` (GPU) and falls back to the `tiny-skia` CPU rasteriser when no usable
adapter is present; `--software-renderer` (or its equivalent `--renderer=cpu`) forces that path.
## Verify the download
`SHA256SUMS.txt` lists exactly the assets attached to this release (Windows x64 and Linux x64, GUI + `ull` each), and
`sha256sum -c` checks the ones you actually downloaded:
```bash
sha256sum -c SHA256SUMS.txt --ignore-missing # Linux / WSL
shasum -a 256 -c SHA256SUMS.txt # macOS (sha256sum is not bundled)
Get-FileHash .\unlucky-launcher_{{VERSION}}_windows_x64.exe -Algorithm SHA256 # Windows PowerShell
```
```
{{SUMS}}
```
## Known limitations of this build
- Microsoft (MSA) sign-in works out of the box: this build carries the Azure public client id it needs
(a public client id, not a credential — it ships inside the binary), so there is nothing to configure
before signing in. Setting `ULL_MSA_CLIENT_ID=<id>` before launching still overrides it; the variable
is read at startup, so restart the launcher after changing it. A build with no id anywhere stops
before any network call, and the GUI banner says so plainly: the title "Microsoft sign-in is not
configured", a body naming `ULL_MSA_CLIENT_ID=<id>` (set it, or build with it, then restart) and
`Code: msa_not_configured`, with no Retry button. Every other sign-in failure keeps the generic
banner plus its own `Code:` line, with the full reason only in the launcher log. `ull accounts add
msa` prints the same fix on the command line. Offline profiles and authlib-injector servers need no
id.
- Binaries are not code-signed / notarized yet: Windows SmartScreen and macOS Gatekeeper warn on first
launch. The checksums above are the integrity reference for now.
- **Linux x64 is verified, not "finished".** The published archive was built on Ubuntu 22.04 and started under a
real display server (WSLg) where it opened a window, rendered a non-blank first frame and printed its theme and
backend lines; CI repeats that with Xvfb on every release. What nobody has looked at yet on a real desktop
distribution: dragging and resizing, font metrics and CJK shaping. Whole-window transparency and rounded corners
are not "unverified" on Linux — they are not code paths there: transparency needs a native window handle the app
does not get (`hwnd=none`), and corners are delegated to the desktop compositor (`corner=policy=system`).
- **On Linux the in-app update does not install itself.** The published Linux asset is a `.tar.gz`, and
`ull update apply` only self-replaces the portable single-file `.exe` form — on Linux it refuses with an explicit
message naming `portable-zip` instead of half-installing (see `crates/core/src/update/job.rs`). Updating on Linux
means downloading the new archive and unpacking it over the old copy; the update check itself (version + channel)
works normally.
- **Linux ARM64 has no runtime evidence at all.** It compiles, and that is the whole story; the artifact is not
attached to this release.
- When the macOS slot ships it is the bare binary (single-file promise), not a `.app` bundle: no Dock
icon or Finder double-click — launch it from a terminal.
## License
Apache-2.0, third-party notices in `NOTICE`. Not affiliated with Mojang or Microsoft; no game files ship.
## UI framework in this build
Every asset here is built from the iced UI (`crates/app-iced`, iced 0.14, MIT); no other UI framework is
linked into it.
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""发布标记(`gh release ... --prerelease`)的**唯一定义处**(M163)。
# 三种触发方式各创建什么
| 触发 | `github.event.inputs.prerelease` | 新建 Release(create) | 已存在 Release(edit) |
| --- | --- | --- | --- |
| push tag `v*`(以及它的 Re-run) | 空串(该字段不存在) | **稳定版**:不带任何标记 | 不传标记(**不碰**) |
| `workflow_dispatch` + `prerelease=true` | `"true"` | 预发布 | 显式改成预发布 |
| `workflow_dispatch` + `prerelease=false` | `"false"` | 稳定版 | 显式改成稳定版 |
空串必须是稳定版:GitHub 的 `releases/latest` **只指向非 prerelease 的最新 Release**,而自更新
客户端把清单地址钉在 `releases/latest/download/manifest.json`(D50)。v0.2.2 之前 push tag 默认打
`--prerelease`,于是 latest 一直 404、更新通道取不到清单——M163 修的就是这条语义。
edit 那一列的空串是「不碰」而不是「改成稳定版」:同 tag 重跑会落到 edit 分支,而用户手工把某个
Release 提成稳定版之后,重跑**不应该**把它翻回预发布。
# 流水线里唯一的走法
python3 packaging/release_marker.py --emit-bash --prerelease "${PRERELEASE:-}" > "$marks"
source "$marks" # 得到 create_mark=(...) / edit_mark=(...)
先落文件再 `source`,而不是 `source <(...)`:非法的 `prerelease` 取值必须让这一步**当场红**
(`set -e` 只认前一条命令的退出码,进程替换里的失败会被吞掉)。
守门在 `packaging/check_release_semantics.py`:它调本文件的 `marks()` 逐条对表,再真的把
`--emit-bash` 灌进 bash 看展开出来的两个数组。本文件的 `--self-test` 只查自己这一层。
"""
from __future__ import annotations
import argparse
import sys
#: 打预发布标记。
PRERELEASE = "--prerelease"
#: 显式摘掉预发布标记(`gh release edit` 用;`create` 不带标记就已经是稳定版)。
STABLE = "--prerelease=false"
#: (触发方式,输入原始值,create 标记,edit 标记)——本仓对外承诺的那张表,逐行可对。
CASES: tuple[tuple[str, str, tuple[str, ...], tuple[str, ...]], ...] = (
("push tag v*(含其 Re-run)", "", (), ()),
("workflow_dispatch + prerelease=true", "true", (PRERELEASE,), (PRERELEASE,)),
("workflow_dispatch + prerelease=false", "false", (), (STABLE,)),
)
def marks(raw: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
"""`github.event.inputs.prerelease` 的原始字符串 → (create 标记, edit 标记)。
只认三种取值(空串 / `"true"` / `"false"`),其它一律抛 :class:`ValueError`——发布流水线
宁可红在一次错误的 dispatch 输入上,也不要把一个自己没预期的值当成「稳定版」发出去。
"""
value = (raw or "").strip()
if value == "":
return (), ()
if value == "true":
return (PRERELEASE,), (PRERELEASE,)
if value == "false":
return (), (STABLE,)
raise ValueError(
f"非预期的 github.event.inputs.prerelease 取值:{raw!r}(只认 空串 / true / false)"
)
def bash_snippet(raw: str) -> str:
"""把两个数组渲染成可直接 `source` 的 bash 片段。"""
create, edit = marks(raw)
return (
"# 由 packaging/release_marker.py 生成,别手改:标记的全部判据都在那个脚本里。\n"
f"create_mark=({' '.join(create)})\n"
f"edit_mark=({' '.join(edit)})\n"
)
def table() -> str:
"""把 :data:`CASES` 渲染成 markdown 表(`--table`;报告与文档直接抄它)。"""
def cell(flags: tuple[str, ...], empty: str) -> str:
return "、".join(f"`{flag}`" for flag in flags) if flags else empty
rows = ["| 触发 | `prerelease` 输入 | create | edit |", "| --- | --- | --- | --- |"]
for trigger, raw, create, edit in CASES:
shown = "空串" if raw == "" else f'`"{raw}"`'
rows.append(f"| {trigger} | {shown} | {cell(create, '(不带标记)')} | {cell(edit, '不碰标记')} |")
return "\n".join(rows)
def self_test() -> int:
"""本文件自己这一层的断言(完整守门见 check_release_semantics.py)。"""
failures: list[str] = []
for trigger, raw, create, edit in CASES:
got = marks(raw)
if got != (create, edit):
failures.append(f"{trigger}:表里写 {(create, edit)},marks({raw!r}) 给的是 {got}")
# 最关键的一条:push tag(空串)的新建路径**不许**带预发布标记。它是 v0.2.2 至今
# `releases/latest` 404 的根因,任何一次重构都不许把它带回来。
push_create, push_edit = marks("")
if PRERELEASE in push_create:
failures.append(f"push tag 的 create 标记里出现了 {PRERELEASE}:稳定通道会一直是 404")
if push_edit:
failures.append(f"push tag 的 edit 标记应为「不碰」,实际是 {push_edit}")
# dispatch 显式 false 时,新建同样是稳定版(create 不需要 `--prerelease=false`:不带就已经是了)。
false_create, false_edit = marks("false")
if PRERELEASE in false_create:
failures.append(f"dispatch prerelease=false 的 create 标记里出现了 {PRERELEASE}")
# 非法值必须炸,不许静默当稳定版(`True` 是 Python 的写法,Actions 的 boolean 输入给的是小写)。
for bad in ("yes", "1", "TRUE", "False", "0", "prerelease"):
try:
marks(bad)
except ValueError:
continue
failures.append(f"非法取值 {bad!r} 被当成了合法输入:{marks(bad)}")
if failures:
for item in failures:
print(f"::error::release_marker 自检失败:{item}", file=sys.stderr)
return 1
print("OK: release_marker 标记表自检通过(push tag → 稳定版)")
print(table())
return 0
def main(argv: list[str]) -> int:
# Windows 控制台是 GBK:本文件的输出里有中文与箭头,缺了这行会在打印时抛
# UnicodeEncodeError(与判据无关的崩溃)。CI 是 UTF-8,不受影响。
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(errors="backslashreplace")
parser = argparse.ArgumentParser(
description="发布标记(--prerelease)的唯一定义处:打印 gh release 要带的参数。"
)
parser.add_argument(
"--prerelease",
default="",
metavar="VALUE",
help='github.event.inputs.prerelease 的原值:空串 / "true" / "false"',
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--emit-bash",
action="store_true",
help="打印可直接 source 的 bash 片段(create_mark / edit_mark 两个数组)",
)
group.add_argument("--table", action="store_true", help="打印触发方式 ↔ 标记的对照表")
group.add_argument("--self-test", action="store_true", help="只查本文件这一层的断言")
args = parser.parse_args(argv)
if args.self_test:
return self_test()
if args.table:
print(table())
return 0
try:
sys.stdout.write(bash_snippet(args.prerelease))
except ValueError as error:
# 走 stderr:stdout 在这种情况下会被重定向进 marks 文件,注解会丢。
print(f"::error::{error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""Sign / verify the update manifest, and check the pinned keyring (M190).
Why Ed25519 + a **detached** `.sig`: the manifest's `sha256` only says "the package matches the
manifest"; it says nothing about who wrote the manifest. A signature over the manifest's *exact
bytes* answers that, and a separate file avoids JSON canonicalisation games (any byte change
breaks the signature).
Key discipline: the **private key** lives only in the repository Secret
`ULL_MANIFEST_SIGNING_KEY` (never in git, logs or artifacts); the **public key** is pinned
*inside the binary* — `crates/core/src/update/trust.rs`, between the `>>> PINNED-KEYS >>>` /
`<<< PINNED-KEYS <<<` markers. `--check-pinned-keyring` fails the release when the secret's key
is not pinned (a release signed with a key clients do not trust is worse than no release).
Manual: `packaging/docs/signing.md`.
Usage: `--generate-key [--key-id K]` / `--sign dist/manifest.json`(钥从环境变量
`ULL_MANIFEST_SIGNING_KEY` 取)/ `--check-pinned-keyring`(钥必须已钉住)/ `--verify-pinned
dist/manifest.json`(用 trust.rs 里钉的公钥回验产出,复现客户端判据)/ `--self-test`。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
from pathlib import Path
FORMAT = 1
ALG = "ed25519"
KEY_ENV = "ULL_MANIFEST_SIGNING_KEY"
SIG_SUFFIX = ".sig"
TRUST_RS = Path(__file__).resolve().parent.parent / "crates/core/src/update/trust.rs"
PIN_BEGIN = ">>> PINNED-KEYS >>>"
PIN_END = "<<< PINNED-KEYS <<<"
KID_RE = re.compile(r'key_id:\s*"([^"]+)"\s*,\s*public_key_hex:\s*"([0-9a-fA-F]{64})"')
# Ed25519 的参考实现搬到了 `packaging/ed25519_ref.py`(M190 r1:本文件贴着 400 行红线)。
# 目的与路径都写在那边的文件头:发布侧签名必须零第三方依赖,而仓里验签用的是 ed25519-dalek ——
# 两套实现互相独立,`--self-test` 用 RFC 8032 的同一组向量把两边各自钉住。
sys.path.insert(0, str(Path(__file__).resolve().parent))
from ed25519_ref import ALG, sign, verify, public_key # noqa: E402
def load_key(text: str) -> tuple[str, bytes]:
"""Parse the secret JSON (`{"key_id":…,"seed_hex":…}`) or a bare 64-char hex seed."""
text = text.strip()
try:
payload = json.loads(text)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict):
key_id = str(payload.get("key_id", "")).strip()
seed_hex = str(payload.get("seed_hex", "")).strip()
else:
key_id, seed_hex = "", text
if not key_id:
raise SystemExit("error: 密钥缺少 `key_id`(清单签名要靠它查信任表)")
try:
seed = bytes.fromhex(seed_hex)
except ValueError as error:
raise SystemExit(f"error: `seed_hex` 不是十六进制({error})") from error
if len(seed) != 32:
raise SystemExit(f"error: `seed_hex` 必须 32 字节(实际 {len(seed)})")
return key_id, seed
def key_from_env_or_arg(path: str | None) -> tuple[str, bytes]:
if path:
secret = Path(path).read_text(encoding="utf-8")
else:
secret = os.environ.get(KEY_ENV, "")
if not secret.strip():
raise SystemExit(
f"error: 没有签名密钥:设环境变量 {KEY_ENV}(内容 = --generate-key 打出来的那一行 JSON),"
"或显式给 --key <文件>。**绝不**把私钥写进仓库/日志/产物。"
)
return load_key(secret)
def pinned_keys(trust_text: str) -> list[tuple[str, str]]:
"""Extract `(key_id, public_key_hex)` pairs between the PINNED-KEYS markers."""
begin = trust_text.find(PIN_BEGIN)
end = trust_text.find(PIN_END)
if begin < 0 or end < 0 or end < begin:
raise SystemExit(f"error: {TRUST_RS} 里找不到 {PIN_BEGIN} / {PIN_END} 标记")
return KID_RE.findall(trust_text[begin:end])
def do_generate_key(key_id: str) -> int:
seed = os.urandom(32)
pk = public_key(seed)
pk_hex = pk.hex()
if not verify(pk, b"self-check", sign(seed, b"self-check")):
print("error: 自己签自己验不过——不要用这把钥", file=sys.stderr)
return 1
secret = json.dumps({"key_id": key_id, "seed_hex": seed.hex()}, ensure_ascii=False)
print("# ① 私钥(**只**贴进仓库 Secret " + KEY_ENV + ",别写进任何文件/日志/提交):")
print(secret)
print("# ② 公钥(贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间):")
print(f' PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},')
print(f"# 公钥十六进制:{pk_hex}")
return 0
def do_sign(manifest: Path, key_path: str | None, out: Path | None, key_id_override: str | None) -> int:
if not manifest.is_file():
print(f"error: 没有清单文件 {manifest}", file=sys.stderr)
return 1
raw = manifest.read_bytes()
# 签的必须是**能解析**的清单:签一串垃圾只会把事故推迟到用户机器上。
try:
parsed = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
print(f"error: {manifest} 不是合法 JSON({error})", file=sys.stderr)
return 1
if not isinstance(parsed, dict) or "channel" not in parsed or "releases" not in parsed:
print(f"error: {manifest} 不像发布清单(缺 channel/releases)", file=sys.stderr)
return 1
key_id, seed = key_from_env_or_arg(key_path)
if key_id_override:
key_id = key_id_override
signature = sign(seed, raw)
if not verify(public_key(seed), raw, signature):
print("error: 签出来的签名自己验不过(实现有问题),拒绝落盘", file=sys.stderr)
return 1
envelope = {
"format": FORMAT,
"signatures": [
{
"alg": ALG,
"key_id": key_id,
"sig": signature.hex(),
"manifest_sha256": hashlib.sha256(raw).hexdigest(),
}
],
}
out = out or manifest.with_name(manifest.name + SIG_SUFFIX)
out.write_text(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
print(f"signed: {manifest} → {out}")
print(f" key_id = {key_id}")
print(f" public_key = {public_key(seed).hex()}")
print(f" manifest_sha256 = {envelope['signatures'][0]['manifest_sha256']}")
return 0
def do_verify(manifest: Path, sig: Path, public_key_hex: str) -> int:
raw = manifest.read_bytes()
envelope = json.loads(sig.read_text(encoding="utf-8"))
pk = bytes.fromhex(public_key_hex)
for item in envelope.get("signatures", []):
if item.get("alg") != ALG:
continue
if verify(pk, raw, bytes.fromhex(item.get("sig", ""))):
print(f"ok: {sig} 里的签名(key_id={item.get('key_id')})通过")
return 0
print("error: 没有任何一条签名能验过", file=sys.stderr)
return 1
def do_check_pinned(key_path: str | None) -> int:
key_id, seed = key_from_env_or_arg(key_path)
pk_hex = public_key(seed).hex()
keys = pinned_keys(TRUST_RS.read_text(encoding="utf-8"))
if not keys:
# 「说人话的红」:因为什么、缺什么、怎么补,一行一行写清楚(不涉密,只回显公钥)。
print("error: 信任表是空的(fail-closed):客户端一把公钥都没有,任何清单都验不过,所以拒绝发布。", file=sys.stderr)
print(f"error: 本次签名钥 = key_id {key_id} / 公钥 {pk_hex}", file=sys.stderr)
print("error: 补法:把这行贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 标记之间 ——", file=sys.stderr)
print(f'error: PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},', file=sys.stderr)
print("error: 手册:packaging/docs/signing.md §3", file=sys.stderr)
return 1
for pinned_id, pinned_hex in keys:
if pinned_hex.lower() == pk_hex:
print(f"ok: 签名钥 {key_id} 已被钉住(trust.rs 里叫 {pinned_id})")
if pinned_id != key_id:
print(
f"warning: 同一个公钥在 trust.rs 里叫 {pinned_id}、密钥文件里叫 {key_id};"
"清单会带后者,客户端按**公钥字节**验签,所以两种写法都能过(但建议改名对齐)",
file=sys.stderr,
)
return 0
pinned_ids = [k[0] for k in keys]
print(
f"error: 签名钥(key_id={key_id} / 公钥={pk_hex})不在信任表里 —— 签出来的清单没有任何客户端会认,所以拒绝发布。",
file=sys.stderr,
)
print(f"error: trust.rs 现在钉的是:{pinned_ids}", file=sys.stderr)
for pinned_id, pinned_hex in keys:
print(f"error: {pinned_id} = {pinned_hex}", file=sys.stderr)
print("error: 二选一:", file=sys.stderr)
print("error: ① 用已钉住的那把钥签(换回对应的 Secret);或", file=sys.stderr)
print(
"error: ② 换钥:把上面的公钥按「旧钥签新钥」贴进 crates/core/src/update/trust.rs 的 PINNED-KEYS 之间,"
"并让本次清单同时带旧钥签名",
file=sys.stderr,
)
print("error: 手册:packaging/docs/signing.md §3/§5", file=sys.stderr)
return 1
def do_verify_pinned(manifest: Path) -> int:
"""用 trust.rs 里钉住的公钥回验 `<manifest>.sig` —— 复现「客户端会怎么判」,不需要私钥。"""
keys = pinned_keys(TRUST_RS.read_text(encoding="utf-8"))
if not keys:
print("error: 信任表是空的,无从回验(先钉公钥,见 --check-pinned-keyring)", file=sys.stderr)
return 1
sig_path = manifest.with_name(manifest.name + SIG_SUFFIX)
if not sig_path.is_file():
print(f"error: 没有签名文件 {sig_path}", file=sys.stderr)
return 1
raw = manifest.read_bytes()
envelope = json.loads(sig_path.read_text(encoding="utf-8"))
for item in envelope.get("signatures", []):
if item.get("alg") != ALG:
continue
try:
signature = bytes.fromhex(item.get("sig", ""))
except ValueError:
continue
for pinned_id, pinned_hex in keys:
if verify(bytes.fromhex(pinned_hex), raw, signature):
print(f"ok: {sig_path.name} 用钉住的公钥 {pinned_id} 能验过(客户端会接受)")
return 0
print(
"error: 拿 trust.rs 里钉住的公钥回验不过 —— 这份 Release 的用户会全部拒装。别发版",
file=sys.stderr,
)
return 1
def do_self_test() -> int:
vectors = [
("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "",
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"),
("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", "72",
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"),
("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", "af82",
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"),
]
failures = 0
for index, (seed_hex, pk_hex, message_hex, sig_hex) in enumerate(vectors, 1):
seed, pk, message = bytes.fromhex(seed_hex), bytes.fromhex(pk_hex), bytes.fromhex(message_hex)
if public_key(seed).hex() != pk_hex:
print(f"FAIL vector {index}: public key mismatch", file=sys.stderr)
failures += 1
if sign(seed, message).hex() != sig_hex:
print(f"FAIL vector {index}: signature mismatch", file=sys.stderr)
failures += 1
if not verify(pk, message, bytes.fromhex(sig_hex)):
print(f"FAIL vector {index}: verify rejected", file=sys.stderr)
failures += 1
tampered = bytearray(message)
tampered.append(0)
if verify(pk, bytes(tampered), bytes.fromhex(sig_hex)):
print(f"FAIL vector {index}: tampered message still verifies", file=sys.stderr)
failures += 1
# 标记解析:信任表解析器必须在示例上命中一对,且在空表上返回空。
sample = f'{PIN_BEGIN}\n PinnedKey {{ key_id: "k1", public_key_hex: "{"ab" * 32}" }},\n{PIN_END}'
if pinned_keys(sample) != [("k1", "ab" * 32)]:
print("FAIL: pinned_keys() 没解析出示例条目", file=sys.stderr)
failures += 1
if pinned_keys(f"{PIN_BEGIN}\n{PIN_END}") != []:
print("FAIL: pinned_keys() 在空表上应返回空", file=sys.stderr)
failures += 1
print("self-test:", "ok" if failures == 0 else f"{failures} 处失败")
return 0 if failures == 0 else 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="更新清单的 Ed25519 签名/验签与信任表检查(M190)")
parser.add_argument("--generate-key", action="store_true", help="生成一把新钥(打印 Secret 与 PinnedKey 片段)")
parser.add_argument("--key-id", default="ull-release", help="生成/签名时用的 key_id")
parser.add_argument("--sign", metavar="MANIFEST", type=Path, help="签这份清单(产出 <清单>.sig)")
parser.add_argument("--out", metavar="SIG", type=Path, help="签名输出路径(默认 <清单>.sig)")
parser.add_argument("--key", metavar="FILE", help=f"密钥文件(不给则读环境变量 {KEY_ENV})")
parser.add_argument("--verify", nargs=2, metavar=("MANIFEST", "SIG"), help="手工验一份签名")
parser.add_argument("--public-key", metavar="HEX", help="--verify 用的公钥")
parser.add_argument("--check-pinned-keyring", action="store_true", help="检查密钥是否已被钉进 trust.rs")
parser.add_argument("--verify-pinned", metavar="MANIFEST", type=Path, help="用 trust.rs 里钉的公钥回验产出")
parser.add_argument("--self-test", action="store_true", help="跑 RFC 8032 向量与解析器自检")
args = parser.parse_args(argv)
if args.self_test:
return do_self_test()
if args.generate_key:
return do_generate_key(args.key_id)
if args.sign:
return do_sign(args.sign, args.key, args.out, args.key_id if args.key_id != "ull-release" else None)
if args.verify:
if not args.public_key:
print("error: --verify 需要 --public-key", file=sys.stderr)
return 1
return do_verify(Path(args.verify[0]), Path(args.verify[1]), args.public_key)
if args.check_pinned_keyring:
return do_check_pinned(args.key)
if args.verify_pinned:
return do_verify_pinned(args.verify_pinned)
parser.print_help()
return 2
if __name__ == "__main__":
raise SystemExit(main())
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""GitHub `if:` 表达式的小求值器(M253 / r2 复审返工,判据 3 用)。
为什么需要它:字符串存在性判据被两轮复审证伪过 —— `if: needs.probe.outputs.available == 'true' || always()`
用 `in` 判照样绿,`if: true` 也绿。所以这里**真求值**:给 `needs.probe.outputs.available` 分别取
`'true'` / `'false'` / `''`,门必须分别判真 / 假 / 假 —— 门到底还挂不挂,由语义决定,不由子串决定。
**只认一小撮语法,其余一律抛 `ConditionError`(调用方按红处理,宁可假红)**:
`==` / `!=` / `&&` / `||` / 括号 / 单引号字符串 / 数字 / `needs.<job>.outputs.<名>` / `true|false|null` /
`always()|success()|failure()|cancelled()`(后四个按「假定上游成功」取常量)。**不支持 `!`**:GitHub 与
Python 的 `!` 优先级不同,认不出比误判安全。不是 GitHub 语义的替身,是它的一个保守子集。
"""
from __future__ import annotations
import re
AVAILABLE_IDENT = "needs.probe.outputs.available"
CALL_FUNCS = {"always": True, "success": True, "failure": False, "cancelled": False}
_TOKEN_RE = re.compile(r"(?P<string>'[^']*')|(?P<number>\d+)|(?P<ident>[A-Za-z_][\w.\-]*)|(?P<op>==|!=|&&|\|\||[()!])")
class ConditionError(Exception):
"""`if:` 表达式认不出 —— 认不出即判红,绝不默认放过。"""
def evaluate_condition(raw, available: str) -> bool:
"""把 GitHub 的 `if:` 表达式求值。只认一小撮语法,其余抛 `ConditionError`(= 判红)。"""
text = str(raw).strip()
if text.startswith("${{") and text.endswith("}}"):
text = text[3:-2].strip()
for name, value in CALL_FUNCS.items():
text = re.sub(rf"\b{name}\s*\(\s*\)", "true" if value else "false", text)
if re.search(r"\b(" + "|".join(CALL_FUNCS) + r")\s*\(", text):
raise ConditionError("函数只支持不带参数的形式")
parts: list[str] = []
pos = 0
while pos < len(text):
if text[pos].isspace():
pos += 1
continue
match = _TOKEN_RE.match(text, pos)
if not match:
raise ConditionError(f"认不出的字符 {text[pos]!r}")
pos = match.end()
token = match.group()
if match.lastgroup in ("string", "number"):
parts.append(token)
elif match.lastgroup == "ident":
if token == AVAILABLE_IDENT:
parts.append("__available")
elif token in ("true", "false", "null"):
parts.append({"true": "True", "false": "False", "null": "None"}[token])
else:
raise ConditionError(f"认不出的标识符 `{token}`")
else:
if token == "!":
raise ConditionError("不支持 `!`(GitHub 与 Python 的优先级不同,宁可认不出)")
parts.append({"&&": "and", "||": "or"}.get(token, token))
source = " ".join(parts)
try:
return bool(eval(source, {"__builtins__": {}}, {"__available": available})) # noqa: S307 - 词表是白名单,见上
except Exception as error: # noqa: BLE001 - 任何求值失败都按「认不出」处理
raise ConditionError(f"求值失败({error}):`{source}`") from None
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""`gpu-evidence.yml` 的**功能断言**部分(M253 / r1+r2 复审返工):把 workflow 里的 `run:` 原文抽出来真跑。
为什么单独一份:判据脚本自己守 ≤400 行硬线,而这里全是「把 `run:` 落盘、用桩 env 起 bash、读
`$GITHUB_OUTPUT` / `$GITHUB_STEP_SUMMARY` / 退出码」的**执行脚手架**,与「哪条判据该红」的判断无关
(同 `gpu_shot_selftest.py` 与 `check_gpu_shot.py` 的分工)。
两组功能断言,各自对应一类「绿而没判」:
**A. `detect` 步(`probe_problems`)—— 跳过要留痕、红线不许退化成跳过。**
字符串存在性检查被 r1 复审证伪过:旧版只要求「同一步的 run 里同时出现 4 个字符串」,于是把
`available` 写成恒 `false`(门禁永久关闭、每次运行都绿)它照样绿 —— **文案都在,执行路径却没走到**。
| # | 输入 | 必须发生什么 |
|---|------|-------------|
| S1 | 无产物(CI 常态) | `available=false` + summary 写「跳过 / 未验证」+ stdout 有 `::warning::` |
| S2 | 约定产物成对 | `available=true` |
| S3 | 约定产物半套(只有日志) | `available=false`(半套不算可用) |
| S4 | dispatch 成对 | `available=true` |
| S5 | dispatch 点名不存在 | `exit≠0`,且**不写** `available`(写了就等于退化成跳过) |
| S6 | dispatch 正例缺 PNG | `exit≠0`,同上 |
| S7 | dispatch 负例只给日志 | `available=true`(`--expect-refuse` 允许省略 PNG) |
**B. `judge` 步(`judge_problems`)—— 判据步必须真的把红判成红。**
r2 复审实测:判据命令后面加 `|| echo "先忽略"`、`; :`、去掉 pipefail 再 `| cat`、`if ! python3 …`、
`true # python3 …`(保留子串锚点),五种写法的判据步**全 exit 0**(绿而没判),而标记表判据一条都没抓住。
所以这里跑两次:正例成对必须 `exit 0`,CPU 档日志(`renderer=tiny-skia`)必须 `exit≠0`。第 2 条一红就
同时封掉「吞码」整类,不依赖任何标记表。
**跑的是原文,只改一个令牌**:`run` 原文里 `python3` 换成本进程的解释器(Windows 上没有 `python3`;
控制流、运算符、参数一字不改)。夹具用 `gpu_shot_selftest.synth_log/synth_png`,不另造第二套。
判据步的 `cwd` 是仓库根(它要 `packaging/check_gpu_shot.py` 的相对路径)。
不降级:找不到能跑的 `bash` 就**判红**(「认不出/跑不了」≠「没问题」)。
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
PACKAGING = Path(__file__).resolve().parent.parent
REPO = PACKAGING.parent
sys.path[:0] = [str(PACKAGING), str(Path(__file__).resolve().parent)]
from gpu_shot_selftest import SELFTEST_SIZE, synth_log, synth_png # noqa: E402
_CACHE: dict[str, list[str]] = {}
PYTHON_TOKEN = re.compile(r"\bpython3\b")
def _find_bash() -> str | None:
"""挑一个**真能跑**的 bash。Windows 上 `bash.exe` 可能是 WSL 的 relay(没有 `/bin/bash`),要验一次。"""
candidates = [os.environ.get("BASH"), shutil.which("bash")]
if os.name == "nt":
candidates += [r"C:\Program Files\Git\bin\bash.exe", r"C:\Program Files\Git\usr\bin\bash.exe"]
candidates.append("bash")
for candidate in candidates:
if not candidate:
continue
try:
done = subprocess.run([candidate, "-c", "echo ok"], capture_output=True, text=True, timeout=60)
except (OSError, subprocess.SubprocessError):
continue
if done.returncode == 0 and "ok" in (done.stdout or ""):
return candidate
return None
def _run(bash: str, script: Path, workdir: Path, env_extra: dict, cwd: str | None = None, flags: tuple[str, ...] = ("-e",)):
"""跑一份 `run:` 原文,返回 `(CompletedProcess, GITHUB_OUTPUT 字典, GITHUB_STEP_SUMMARY 文本)`。
`flags` 默认 `-e`(GitHub 的 `bash -e {0}`);判据步还会用**裸 bash**(不带 `-e`)再跑一遍 ——
`... ; :` 这种写法只有在不靠 `-e` 兜底时才会把红吞掉(实测:`bash -e` 仍 exit 1,裸 bash exit 0)。
"""
out_path, sum_path = workdir / "gh_output", workdir / "gh_summary"
env = os.environ.copy()
env.update({"GITHUB_OUTPUT": out_path.as_posix(), "GITHUB_STEP_SUMMARY": sum_path.as_posix()})
env.update({key: ("" if value is None else str(value)) for key, value in env_extra.items()})
done = subprocess.run([bash, *flags, str(script)], cwd=cwd or str(workdir), env=env, capture_output=True, text=True, encoding="utf-8", errors="replace")
outputs: dict[str, str] = {}
if out_path.exists():
for line in out_path.read_text(encoding="utf-8", errors="replace").splitlines():
key, sep, value = line.partition("=")
if sep:
outputs[key.strip()] = value
summary = sum_path.read_text(encoding="utf-8", errors="replace") if sum_path.exists() else ""
return done, outputs, summary
def _case(label: str, done, outputs: dict, summary: str, *, available: str, contains: tuple[str, ...] = (), warning: bool = False) -> list[str]:
problems: list[str] = []
if done.returncode != 0:
problems.append(f"{label}:退出码 {done.returncode},应为 0(stderr: {(done.stderr or '').strip()[:200]})")
if outputs.get("available") != available:
problems.append(f"{label}:`available={outputs.get('available')!r}`,应为 {available!r} —— 探测结论被改坏了,门就永久开或永久关")
for needle in contains:
if needle not in summary:
problems.append(f"{label}:job summary 里缺 `{needle}` —— 跳过不再留痕")
if warning and "::warning::" not in (done.stdout or ""):
problems.append(f"{label}:stdout 里没有 `::warning::` —— 跳过变成了静默 pass")
return problems
def _probe_scenarios(bash: str, script_text: str) -> list[str]:
problems: list[str] = []
with tempfile.TemporaryDirectory(prefix="ull-gpu-ci-", ignore_cleanup_errors=True) as tmp:
root = Path(tmp)
fixtures = root / "fixtures"
fixtures.mkdir()
log = fixtures / "a1-gpu.stderr.log"
log.write_text("[shot] 200x120\n", encoding="utf-8")
png = fixtures / "a1-gpu.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n")
def case(slug: str, tree: tuple[tuple[str, bytes], ...] = ()):
workdir = root / slug
workdir.mkdir()
for rel, payload in tree:
path = workdir / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(payload)
script = workdir / "probe.sh"
script.write_text(script_text, encoding="utf-8")
return script, workdir
base = {"EVENT": "push", "SHOT_LOG": "", "SHOT_PNG": "", "EXPECT_REFUSE": None}
convention = "DevDocs/artifacts/gpu-probe/gpu-shot"
script, workdir = case("s1-none")
problems += _case("S1 无产物(CI 常态)", *_run(bash, script, workdir, base), available="false", contains=("跳过", "未验证"), warning=True)
script, workdir = case("s2-convention-pair", ((f"{convention}.stderr.log", b"x"), (f"{convention}.png", b"x")))
problems += _case("S2 约定产物成对", *_run(bash, script, workdir, base), available="true")
script, workdir = case("s3-convention-half", ((f"{convention}.stderr.log", b"x"),))
problems += _case("S3 约定产物半套(只有日志)", *_run(bash, script, workdir, base), available="false", contains=("跳过",))
script, workdir = case("s4-dispatch-pair")
problems += _case("S4 dispatch 成对", *_run(bash, script, workdir, dict(base, SHOT_LOG=str(log), SHOT_PNG=str(png))), available="true")
for slug, label, extra in (
("s5-dispatch-missing", "S5 dispatch 点名不存在", {"SHOT_LOG": str(root / "nope.log"), "SHOT_PNG": str(png)}),
("s6-dispatch-no-png", "S6 dispatch 正例缺 PNG", {"SHOT_LOG": str(log), "SHOT_PNG": ""}),
):
script, workdir = case(slug)
done, outputs, _summary = _run(bash, script, workdir, dict(base, **extra))
if done.returncode == 0:
problems.append(f"{label}:退出码 0 —— 「明确说要判、东西不在」退化成了跳过(假绿)")
if outputs:
problems.append(f"{label}:该红却写了 `$GITHUB_OUTPUT`({outputs})")
script, workdir = case("s7-dispatch-refuse")
problems += _case("S7 dispatch 负例只给日志", *_run(bash, script, workdir, dict(base, SHOT_LOG=str(log), SHOT_PNG="", EXPECT_REFUSE="true")), available="true")
return problems
def probe_problems(script_text: str) -> list[str]:
"""判据 A:`id: detect` 那一步的 `run` 原文实跑 7 种输入(带缓存,同文本只跑一次)。"""
if not script_text.strip():
return ["`id: detect` 那一步没有 `run:` —— 跳过路径无从执行"]
if script_text not in _CACHE:
bash = _find_bash()
_CACHE[script_text] = (
["找不到能跑的 `bash`:跳过路径的功能断言无法执行,本判据不降级(认不出就红)"]
if not bash
else _probe_scenarios(bash, script_text)
)
return _CACHE[script_text]
def _judge_scenarios(bash: str, script_text: str) -> list[str]:
problems: list[str] = []
with tempfile.TemporaryDirectory(prefix="ull-gpu-judge-", ignore_cleanup_errors=True) as tmp:
root = Path(tmp)
passing = root / "pass.stderr.log"
passing.write_text(synth_log(size=SELFTEST_SIZE), encoding="utf-8")
cpu = root / "cpu.stderr.log"
cpu.write_text(synth_log(renderer="tiny-skia", size=SELFTEST_SIZE), encoding="utf-8")
png = root / "pass.png"
png.write_bytes(synth_png("content", SELFTEST_SIZE))
script = root / "judge.sh"
script.write_text(PYTHON_TOKEN.sub(lambda _match: f'"{Path(sys.executable).as_posix()}"', script_text), encoding="utf-8")
common = {"PNG": png.as_posix(), "EXPECT_REFUSE": "false"}
# 两种模式都跑:`-e` = GitHub 的默认 shell;裸 bash = 不靠 `-e` 兜底(`; :` 只在后者现形)。
for mode, flags in (("bash -e", ("-e",)), ("裸 bash", ())):
for label, log_path, want_zero in (
("正例(六条判据全过)", passing, True),
("CPU 档日志(`renderer=tiny-skia`,必须判红)", cpu, False),
):
done, _outputs, _summary = _run(bash, script, root, dict(common, LOG=log_path.as_posix()), cwd=str(REPO), flags=flags)
if want_zero and done.returncode != 0:
problems.append(
f"[{mode}] 判据步对**正例**退出码 {done.returncode},应为 0 —— 「判据全过」这条路走不通了"
f"(stderr: {(done.stderr or '').strip()[:200]})"
)
if not want_zero and done.returncode == 0:
problems.append(
f"[{mode}] 判据步对**必须判红**的输入退出码 0 —— 「绿而没判」:`|| echo` / `; :` / 管道 / `if !` / "
"`true # 原行` / 裸 `exit 0` 都属于这一类"
)
return problems
def judge_problems(script_text: str) -> list[str]:
"""判据 B:`id: judge` 那一步的 `run` 原文实跑两次(正例必须 0、CPU 档必须非 0)。"""
if not script_text.strip():
return ["`id: judge` 那一步没有 `run:` —— 判据步无从执行"]
key = "judge:" + script_text
if key not in _CACHE:
bash = _find_bash()
_CACHE[key] = (
["找不到能跑的 `bash`:判据步的功能断言无法执行,本判据不降级(认不出就红)"]
if not bash
else _judge_scenarios(bash, script_text)
)
return _CACHE[key]
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env python3
r"""`gpu-evidence.yml` 的**接线自检**(M253 / r1+r2 复审返工):把「这道门禁怎么才算没被改绿」变成判据。
历史(留着,防止下一个人再写绝对化结论):
- r1 版宣称「不可能被改成总是绿」,复审造 19 条变异**漏 11 条**(最要命:`available` 恒 false ⇒ 门永久
关闭而每次绿)—— 根因是判据只做字符串存在性检查、自测步被整条豁免、`is True` 挡不住 `'true'`。
- r2 版改成 6 条判据后,复审 40 条新变异**仍漏 11 条**:job outputs 的 `available` 绑到 `reason`/字面量
(门永久关、每次绿)、判据步 `|| echo "先忽略"` / `; :` / `| cat` / `if !` / `true # 原行`(**绿而没判**,
真 bash 实测 exit 0)、自检步被指向另一个脚本(自检根本不跑)、`on:` 触发面被删。
教训:**「不吞退出码」不能靠标记表**,得把 `run` 原文真跑一遍。
现在十条判据,能结构化的不靠字符串、能跑的不靠读文本:
| # | 判据 | 怎么判 |
|---|------|--------|
| 1 | 五步必须在场 | 按 `id:` 认 `vectors`/`exitcodes`/`wiring`/`detect`/`judge`,各恰好一步;前四步不许有 `if:`(step 或 job 级);`exitcodes` 必须真断言 `expect 0/1/2` |
| 2 | 自测步跑的是对脚本、且是单条命令 | `wiring` 的 `run` 必须含 `gpu_shot_ci_selftest.py`;`vectors`/`wiring` 的 `run` 不许有换行、`\|`、`&`、`;`、反引号、`#`、`$(` —— 否则红了之后一行 `\|\| echo 先忽略` 就能关掉它 |
| 3 | 判定必须挂门 | **解析 `if:` 表达式后求值**:`available` 取 `'true'` 必须真、`'false'`/`''` 必须假;job 级与 step 级各自都要满足;认不出的表达式一律红 |
| 4 | 判定用探测给的产物 | `run` 里两种调用形态都在场(`"$LOG" "$PNG"` 与 `"$LOG" --expect-refuse`),`env` 来自 `needs.probe.outputs.*` |
| 5 | 判据步**真跑两次** | `judge` 的 `run` 原文实跑:正例成对必须 `exit 0`,CPU 档日志(`renderer=tiny-skia`)必须 `exit≠0` —— 一次封掉整类「吞码」 |
| 6 | 跳过路径**真跑 7 种输入** | `detect` 的 `run` 原文实跑:`available` 的值、summary 文案、`::warning::`、三条红线必须 `exit≠0` 且不写 outputs |
| 7 | 禁 `continue-on-error` | 按 YAML 布尔语义:`true` / `'true'` / `${{ true }}` / 任意表达式都算开启 |
| 8 | 不吞退出码(标记表) | 扫所有 `python`+`packaging/` 的 `run`:`\|\| true` / `; true` / `\|\| exit 0` / 裸 `exit 0`(判据 5 的功能断言才是主守,这张表只是兜底) |
| 9 | `probe.outputs` 必须绑到 `detect` | `available`/`log`/`png` 三个键的值必须**逐个**等于 `${{ steps.detect.outputs.<同名> }}` |
| 10 | 触发面 | `on:` 必须同时有 `push`(含 `main`)、`pull_request`、`workflow_call` |
## 覆盖的「改绿」类别(封闭清单:10 类,33 条变异向量)
变异数据在 `packaging/tests/gpu_shot_ci_vectors.json`,**按解析后的结构注入**(`step_delete` /
`job_delete` / `step_set` / `job_set` / `path_set` / `trigger_set` / `run_append` / `run_replace`,
`also` 可叠加),每条都必须让对应判据当场红、基线必须全绿。类别与来源:
1. 步骤被删 / 被 `if:` 挡住 / 被 `continue-on-error` 兜住(r1 M7/M9/M10/M13/M15/M16/M17/M18、r2 N15/N18);
2. 自测步指向别的脚本、或写成能被 `|| echo` 关掉的多行/带运算符命令(r2 N40/N41/N23);
3. 判定的门被写成恒真(`|| always()`、`if: true`、step 级 `if: ${{ always() }}`、删门)(r1 M3/M19、r2 N4/N5);
4. 判定的产物路径被写死或调用形态被换掉(r1 M20、r2 N9);
5. 判据步的退出码被吞 / 分支没走到(r1 M4、r2 N22/N24/N25/N26/N8'—— 都用真 bash 实跑判据步验证);
6. `detect` 的结论被改坏(`available` 恒 false、跳过不留痕、删 `$GITHUB_OUTPUT`)(r1 M11/M12/M5/M6/M14、r2 N6/N7/N20);
7. `probe.outputs.*` 被改指向别的 step 输出或字面量(**r2 N28/N29**);
8. `on:` 触发面被削(删 `push`/`pull_request`/`workflow_call`)(**r2 N11/N12**);
9. 排版变动**不误红**(加注释/空行基线仍绿)、变异锚点坏掉**报「向量失效」并红**(r2 N16/N17);
10. `if:` 求值器不接受近似写法(`!=`、`!(…)`、`fromJSON`、未知标识符一律红)(r2 N1/N2/N3)。
## 明确**不覆盖**(边界,逐条给理由)
- **改判据脚本本身**:能改这个文件的人也能改判据,它不自守。它只防「顺手改绿」,不防恶意改绿。
- **载体依赖**:把整个 `gpu-evidence.yml` 删掉/换成别的文件,就没有任何东西会跑这个自检 —— 守门人住在
被守对象自己的文件里。`guards.yml` 已经 399/400 行(`file-size` 那道门就扫 `*.yml`),塞不进去。
- **`if:` 求值器与 GitHub 语义的差异**:只认 `==`/`!=`/`&&`/`||`/括号/字符串/`needs.<job>.outputs.<名>`/
`true|false|null`/`always()|success()|failure()|cancelled()`(后四者取常量);其余认不出就是红。
- **判据 4 的「合法调用替换」**:把正例分支换成另一种**合法**调用(`"$PNG" --expect-refuse`)抓不到;
后果是假红不是假绿,判据 5 也会把它判红(正例跑成非 0)。
- **`exitcodes` 那一步的断言被削弱**(例如末尾加 `|| echo`):它是**见证步**,不是门;门的绿由判据 5/6
的功能断言守住,见证步被削只影响「退出码语义也被验过」这条证据。
- **判据 6 的 7 种输入之外的分支**(例如 `detect` 里新增的第五个分支)。
- **真 Actions 的行为**:`if:` 的真求值、job skip 的呈现、required status check 与 skipped 的交互,
本脚本一概不管(`if:` 语义是本脚本的小求值器模拟的)。
## 自我定位与止步线
本文件**不宣称**「不可能被改成总是绿」,只宣称「上面那张封闭清单(10 类 / 33 条向量)会被抓」。
**这条门禁自 2026-11-11 起停止加固**:再往下就会无限递归(守卫守守卫守守卫),边际收益已很低。
后续若要扩覆盖面,必须由**人类新开 mission**,不要在同一轮里继续加判据。
python3 packaging/tests/gpu_shot_ci_selftest.py
"""
from __future__ import annotations
import copy
import json
import re
import sys
from pathlib import Path
PACKAGING = Path(__file__).resolve().parent.parent
WORKFLOW = PACKAGING.parent / ".github/workflows/gpu-evidence.yml"
VECTORS_PATH = Path(__file__).resolve().parent / "gpu_shot_ci_vectors.json"
sys.path.insert(0, str(PACKAGING))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from check_workflows_yaml import load_yaml # noqa: E402
from gpu_shot_ci_expr import ConditionError, evaluate_condition # noqa: E402
from gpu_shot_ci_probe import judge_problems, probe_problems # noqa: E402
#: 必须在场、且必须无条件的步骤(按 `id:` 认,不按步骤名认)。
REQUIRED_STEPS = {
"vectors": "判据实现自测(`--self-test`,19 条向量)",
"exitcodes": "退出码语义(真 CLI 走 0/1/2)",
"wiring": "接线自检(本脚本,必须在场)",
"detect": "产物探测(probe 的唯一步骤)",
"judge": "真实产物判据(evidence 的步骤)",
}
UNCONDITIONAL = ("vectors", "exitcodes", "wiring", "detect")
PROBE_OUTPUT_KEYS = ("available", "log", "png")
SINGLE_COMMAND_STEPS = ("vectors", "wiring")
SHELL_OPERATOR = re.compile(r"[|&;`#$()<>\n]")
SWALLOW_MARKS = ("|| true", "; true", "|| exit 0", "exit 0")
PYTHON_RE = re.compile(r"\bpython3?\b")
def coerce_flag(value) -> bool:
"""YAML 里的开关按**真语义**判:`true` / `'true'` / `${{ true }}` 都算开启。"""
if value is True:
return True
if value is False or value is None:
return False
if isinstance(value, (int, float)):
return bool(value)
return str(value).strip().lower() not in {"false", "${{ false }}", "0", "no", "off", ""}
def steps(doc: dict) -> list[tuple[str, dict, dict]]:
found: list[tuple[str, dict, dict]] = []
for job_id, job in (doc.get("jobs") or {}).items():
for step in (job or {}).get("steps") or []:
if isinstance(step, dict):
found.append((job_id, job, step))
return found
def step_by_id(doc: dict, sid: str) -> tuple[str, dict, dict] | None:
found = [entry for entry in steps(doc) if entry[2].get("id") == sid]
return found[0] if len(found) == 1 else None
def run_of(doc: dict, sid: str) -> str:
entry = step_by_id(doc, sid)
return (entry[2].get("run") or "") if entry else ""
def check_presence(doc: dict) -> list[str]:
problems: list[str] = []
counts = {sid: len([e for e in steps(doc) if e[2].get("id") == sid]) for sid in REQUIRED_STEPS}
for sid, label in REQUIRED_STEPS.items():
if counts[sid] != 1:
problems.append(f"`id: {sid}`({label})必须恰好一步,实际 {counts[sid]} 步 —— 删掉或复制都算红")
continue
job_id, job, step = step_by_id(doc, sid)
if sid in UNCONDITIONAL and ("if" in step or "if" in job):
problems.append(f"`id: {sid}`({label})带 `if:`(步骤或 job 级)—— 这四步必须无条件跑,否则门禁可被静默关掉")
asserted = set(re.findall(r"\bexpect\s+([0-9]+)\b", run_of(doc, "exitcodes")))
missing = {"0", "1", "2"} - asserted
if step_by_id(doc, "exitcodes") and missing:
problems.append(f"`id: exitcodes` 没有断言退出码 {'/'.join(sorted(missing))}(`expect <码>`)—— 三个码就是门禁的接口")
return problems
def check_selftest_commands(doc: dict) -> list[str]:
problems: list[str] = []
wiring = run_of(doc, "wiring").strip()
if wiring and "gpu_shot_ci_selftest.py" not in wiring:
problems.append("`id: wiring` 的 `run` 里没有 `gpu_shot_ci_selftest.py` —— 它可能指向别的脚本(自检根本没跑)")
for sid in SINGLE_COMMAND_STEPS:
run = run_of(doc, sid).strip()
if not run:
continue
if SHELL_OPERATOR.search(run):
problems.append(
f"`id: {sid}` 的 `run` 必须是**单条命令**(不许换行 / `|` / `&` / `;` / 反引号 / `#` / `$(`)"
f"—— 否则红了之后一行 `|| echo 先忽略` 就能把它关掉:`{run}`"
)
return problems
def check_judge_gate(doc: dict) -> list[str]:
entry = step_by_id(doc, "judge")
if entry is None:
return [] # presence 已经报过
job_id, job, step = entry
gates: list[tuple[str, object]] = []
if "if" in job:
gates.append((f"job `{job_id}` 的 `if`", job.get("if")))
if "if" in step:
gates.append(("`id: judge` 步的 `if`", step.get("if")))
if not gates:
return ["`judge` 步与它的 job 都没有 `if:` —— 无产物时也会照跑,「跳过」与「通过」分不开"]
problems: list[str] = []
for label, raw in gates:
for available, want in (("true", True), ("false", False), ("", False)):
try:
got = evaluate_condition(raw, available)
except ConditionError as error:
problems.append(f"{label} 认不出({error})—— 认不出即红:`{raw}`")
break
if got is not want:
problems.append(f"{label} 在 `available={available!r}` 时求值为 {got},应为 {want}:`{raw}`")
return problems
def check_judge_invocation(doc: dict) -> list[str]:
entry = step_by_id(doc, "judge")
if entry is None:
return []
run = entry[2].get("run") or ""
env = entry[2].get("env") or {}
problems: list[str] = []
for form in ('check_gpu_shot.py "$LOG" "$PNG"', 'check_gpu_shot.py "$LOG" --expect-refuse'):
if form not in run:
problems.append(f"`id: judge` 的 `run` 里缺调用形态 `{form}` —— 该分支可以「什么都没判」就绿")
for name, ref in (("LOG", "needs.probe.outputs.log"), ("PNG", "needs.probe.outputs.png")):
if ref not in str(env.get(name, "")):
problems.append(f"`judge` 的 `{name}` 不是来自 `{ref}`(写死的路径会在跳过那条路上判错东西)")
return problems
def check_probe_outputs(doc: dict) -> list[str]:
outputs = (doc.get("jobs") or {}).get("probe", {}).get("outputs") or {}
problems: list[str] = []
for key in PROBE_OUTPUT_KEYS:
want = "${{ steps.detect.outputs.%s }}" % key
got = str(outputs.get(key, "")).strip()
if got != want:
problems.append(
f"`probe.outputs.{key}` 必须恰好是 `{want}`,实际 {got!r} —— "
"改指向(`reason` / 字面量 `'false'`)会让门永久关掉,而每次运行都绿"
)
return problems
def check_triggers(doc: dict) -> list[str]:
on_block = doc.get("on", doc.get(True))
if not isinstance(on_block, dict):
return ["`on:` 不是一个映射 —— 触发面认不出(认不出即红)"]
problems: list[str] = []
if "push" not in on_block:
problems.append("`on:` 少了 `push` —— main 上的门禁会停摆")
else:
branches = (on_block.get("push") or {}).get("branches") if isinstance(on_block.get("push"), dict) else None
if branches is not None and "main" not in [str(branch) for branch in branches]:
problems.append("`on.push.branches` 里没有 `main` —— main 上的门禁会停摆")
if "pull_request" not in on_block:
problems.append("`on:` 少了 `pull_request` —— PR 上的门禁会停摆")
if "workflow_call" not in on_block:
problems.append("`on:` 少了 `workflow_call` —— `dev-guards.yml` 的 `uses:` 会加载失败")
return problems
def check_continue_on_error(doc: dict) -> list[str]:
problems: list[str] = []
for job_id, job in (doc.get("jobs") or {}).items():
if coerce_flag((job or {}).get("continue-on-error")):
problems.append(f"job `{job_id}` 带 `continue-on-error: {job.get('continue-on-error')!r}` —— 判据红也会变绿")
for step in (job or {}).get("steps") or []:
if isinstance(step, dict) and coerce_flag(step.get("continue-on-error")):
problems.append(f"job `{job_id}` 的 `{step.get('id') or step.get('name')}` 带 `continue-on-error: {step.get('continue-on-error')!r}`")
return problems
def check_no_swallow(doc: dict) -> list[str]:
problems: list[str] = []
for job_id, _job, step in steps(doc):
run = step.get("run") or ""
if not (PYTHON_RE.search(run) and "packaging/" in run):
continue
hits = [mark for mark in SWALLOW_MARKS if mark in run]
if hits:
problems.append(f"job `{job_id}` 的 `{step.get('id') or step.get('name')}` 里出现 {'/'.join(f'`{h}`' for h in hits)} —— 退出码被吞掉,判据红也绿")
return problems
CHECKS = {
"1 五步必须在场": check_presence,
"2 自测步跑的是对脚本且是单条命令": check_selftest_commands,
"3 判定必须挂门": check_judge_gate,
"4 判定用探测给的产物": check_judge_invocation,
"5 判据步真跑(正例 0 / CPU 档非 0)": lambda doc: judge_problems(run_of(doc, "judge")),
"6 跳过路径真跑(7 种输入)": lambda doc: probe_problems(run_of(doc, "detect")),
"7 禁 continue-on-error": check_continue_on_error,
"8 不吞退出码(标记表,兜底)": check_no_swallow,
"9 probe.outputs 绑定 detect": check_probe_outputs,
"10 触发面(push/pull_request/workflow_call)": check_triggers,
}
def evaluate(doc: dict) -> dict[str, list[str]]:
if not isinstance(doc, dict) or not isinstance(doc.get("jobs"), dict):
return {name: ["workflow 不是一个带 jobs 的 YAML 映射"] for name in CHECKS}
return {name: check(doc) for name, check in CHECKS.items()}
def _walk(doc: dict, path: list) -> tuple[dict, object]:
node = doc
for key in path[:-1]:
node = node[key]
return node, path[-1]
def apply_vector(doc: dict, vector: dict) -> dict:
"""按**解析后的结构**注入变异(不碰文本格式,改动 workflow 的排版不会让向量过期)。"""
op = vector["op"]
target = str(vector.get("target", ""))
on_key = "on" if "on" in doc else True
if op in ("step_delete", "step_set", "run_append", "run_replace"):
entry = step_by_id(doc, target)
if entry is None:
raise KeyError(f"找不到唯一的 `id: {target}`")
step = entry[2]
if op == "step_delete":
entry[1]["steps"].remove(step)
elif op == "step_set":
step[vector["key"]] = vector["value"]
else:
text = step.get("run") or ""
if op == "run_append":
step["run"] = text + vector["value"]
else:
find, replacement, occurrence = vector["find"], vector["replace"], int(vector.get("occurrence", 1))
pos = -1
for _ in range(occurrence):
pos = text.find(find, pos + 1)
if pos < 0:
raise KeyError(f"`id: {target}` 的 run 里找不到第 {occurrence} 处 `{find}`")
step["run"] = text[:pos] + replacement + text[pos + len(find) :]
elif op in ("job_delete", "job_set"):
if target not in (doc.get("jobs") or {}):
raise KeyError(f"没有 job `{target}`")
if op == "job_delete":
del doc["jobs"][target]
else:
doc["jobs"][target][vector["key"]] = vector["value"]
elif op == "path_set":
parent, key = _walk(doc, vector["path"])
if key not in parent:
raise KeyError(f"路径不存在:{vector['path']}")
parent[key] = vector["value"]
elif op == "trigger_set":
doc[on_key] = vector["value"]
else:
raise KeyError(f"不认识的变异操作 `{op}`")
for extra in vector.get("also", []):
apply_vector(doc, extra)
return doc
def main() -> int:
yaml = load_yaml()
if not WORKFLOW.is_file():
print(f"::error file={WORKFLOW}::接线自检的靶子不存在", file=sys.stderr)
return 1
doc = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
failures = 0
for name, problems in evaluate(doc).items():
if problems:
failures += 1
print(f"FAIL: 判据「{name}」没过:", file=sys.stderr)
for problem in problems:
print(f" - {problem}", file=sys.stderr)
else:
print(f"OK: 判据「{name}」")
vectors = json.loads(VECTORS_PATH.read_text(encoding="utf-8"))["vectors"]
for vector in vectors:
label = f"{vector['id']} {vector['label']}"
try:
mutated = apply_vector(copy.deepcopy(doc), vector)
except Exception as error: # noqa: BLE001 - 向量失效(锚点没了)同样算红,逼着人更新它
failures += 1
print(f"FAIL: 变异「{label}」注入失败(向量失效):{error}", file=sys.stderr)
continue
broken = [name for name, problems in evaluate(mutated).items() if problems]
if vector["expect"] not in broken:
failures += 1
print(f"FAIL: 变异「{label}」没被抓到(应红「{vector['expect']}」,实际红的是 {broken or '无'})", file=sys.stderr)
else:
print(f"OK: 变异「{label}」→ 「{vector['expect']}」当场红")
if failures:
print(f"FAIL:{failures} 处没过({WORKFLOW.name})", file=sys.stderr)
return 1
print(f"ok: {len(CHECKS)} 条接线判据全过,{len(vectors)} 条变异向量全部被抓({WORKFLOW.name})")
print(" 封闭清单 10 类(见 docstring);不覆盖:改判据脚本 / 删掉整个 workflow(载体依赖)/ 求值器语义差 / 合法调用替换。")
print(" 本门禁自 2026-11-11 起停止加固,扩项须由人类新开 mission。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+38
View File
@@ -0,0 +1,38 @@
{
"_note": "M253 接线自检的变异向量。全部按解析后的结构注入(排版变动不影响):op ∈ step_delete / job_delete / step_set / job_set / path_set / trigger_set / run_append / run_replace;`also` 可叠加一组后续注入。expect = 必须报错的判据名(与 gpu_shot_ci_selftest.py 的 CHECKS 键逐字一致)。计数:33 = r1 复审表 20 条 + 1 条补充(M20)+ r2 复审 12 条(N28/N29/N22/N23/N24/N25/N26/N8'/N40/N41/N11/N12)。",
"vectors": [
{"id": "M1", "label": "自测步命令后加 || true(自测被吞)", "op": "run_append", "target": "vectors", "value": " || true", "expect": "8 不吞退出码(标记表,兜底)"},
{"id": "M2", "label": "接线自检步命令后加 || true(守卫守不到自己)", "op": "run_append", "target": "wiring", "value": " || true", "expect": "8 不吞退出码(标记表,兜底)"},
{"id": "M2b", "label": "接线自检步命令后加 ; true", "op": "run_append", "target": "wiring", "value": "; true", "expect": "2 自测步跑的是对脚本且是单条命令"},
{"id": "M3", "label": "判定的门加 || always()(门形同虚设)", "op": "job_set", "target": "evidence", "key": "if", "value": "needs.probe.outputs.available == 'true' || always()", "expect": "3 判定必须挂门"},
{"id": "M4", "label": "判据命令后加裸 exit 0", "op": "run_append", "target": "judge", "value": "\nexit 0\n", "expect": "8 不吞退出码(标记表,兜底)"},
{"id": "M5", "label": "整行删掉 ::warning:: 告警", "op": "run_replace", "target": "detect", "find": "echo \"::warning::GPU 证据:跳过(${reason})—— 本次运行没有判过任何真实产物,不构成「GPU 证据已验证」\"", "replace": "", "expect": "6 跳过路径真跑(7 种输入)"},
{"id": "M6", "label": "不再写 $GITHUB_OUTPUT(判定永远拿不到产物)", "op": "run_replace", "target": "detect", "find": ">> \"$GITHUB_OUTPUT\"", "replace": ">> /dev/null", "expect": "6 跳过路径真跑(7 种输入)"},
{"id": "M7", "label": "selftest job 加 job 级 if: false", "op": "job_set", "target": "selftest", "key": "if", "value": false, "expect": "1 五步必须在场"},
{"id": "M8", "label": "探测步加 continue-on-error: true", "op": "step_set", "target": "detect", "key": "continue-on-error", "value": true, "expect": "7 禁 continue-on-error"},
{"id": "M9", "label": "接线自检步加 if: false", "op": "step_set", "target": "wiring", "key": "if", "value": false, "expect": "1 五步必须在场"},
{"id": "M10", "label": "整步删掉接线自检", "op": "step_delete", "target": "wiring", "expect": "1 五步必须在场"},
{"id": "M11", "label": "dispatch 分支 available 恒 false(门禁永久关闭)", "op": "run_replace", "target": "detect", "find": "available=true", "replace": "available=false", "occurrence": 1, "expect": "6 跳过路径真跑(7 种输入)"},
{"id": "M12", "label": "约定产物分支 available 恒 false(门禁永久关闭)", "op": "run_replace", "target": "detect", "find": "available=true", "replace": "available=false", "occurrence": 2, "expect": "6 跳过路径真跑(7 种输入)"},
{"id": "M13", "label": "整块删掉 evidence job", "op": "job_delete", "target": "evidence", "expect": "1 五步必须在场"},
{"id": "M14", "label": "::warning:: 改成裸 echo(跳过变静默 pass)", "op": "run_replace", "target": "detect", "find": "::warning::", "replace": "", "expect": "6 跳过路径真跑(7 种输入)"},
{"id": "M15", "label": "整步删掉 --self-test", "op": "step_delete", "target": "vectors", "expect": "1 五步必须在场"},
{"id": "M16", "label": "整步删掉退出码语义 0/1/2", "op": "step_delete", "target": "exitcodes", "expect": "1 五步必须在场"},
{"id": "M17", "label": "continue-on-error: 'true'(带引号)", "op": "step_set", "target": "detect", "key": "continue-on-error", "value": "true", "expect": "7 禁 continue-on-error"},
{"id": "M18", "label": "continue-on-error: ${{ true }}(表达式)", "op": "step_set", "target": "detect", "key": "continue-on-error", "value": "${{ true }}", "expect": "7 禁 continue-on-error"},
{"id": "M19", "label": "判定步加 if: ${{ always() }}", "op": "step_set", "target": "judge", "key": "if", "value": "${{ always() }}", "expect": "3 判定必须挂门"},
{"id": "M20", "label": "判定步的正例调用换成 true(不再调判据脚本)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "true", "expect": "4 判定用探测给的产物"},
{"id": "N28", "label": "probe.outputs.available 改指向 reason(门永久关、每次绿)", "op": "path_set", "path": ["jobs", "probe", "outputs", "available"], "value": "${{ steps.detect.outputs.reason }}", "expect": "9 probe.outputs 绑定 detect"},
{"id": "N29", "label": "probe.outputs.available 改成字面量 'false'(门永久关)", "op": "path_set", "path": ["jobs", "probe", "outputs", "available"], "value": "false", "expect": "9 probe.outputs 绑定 detect"},
{"id": "N22", "label": "判据命令后加 || echo 先忽略(绿而没判)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\" || echo \"判据没过(先忽略)\"", "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
{"id": "N24", "label": "判据命令后加 ; :(吞退出码)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\" ; :", "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
{"id": "N25", "label": "去掉 pipefail 再把判据命令接管道 | cat(吞退出码)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\" | cat", "also": [{"op": "run_replace", "target": "judge", "find": "set -uo pipefail", "replace": "set -u"}], "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
{"id": "N26", "label": "判据命令包进 if ! … then echo; fi(吞退出码)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "if ! python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"; then echo \"没判过\"; fi", "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
{"id": "N8b", "label": "判据命令改成 true 后注释掉原行(保留子串锚点)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "true # python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
{"id": "N40", "label": "接线自检步指向 gpu_shot_ci_probe.py(自检根本不跑)", "op": "step_set", "target": "wiring", "key": "run", "value": "python3 packaging/tests/gpu_shot_ci_probe.py", "expect": "2 自测步跑的是对脚本且是单条命令"},
{"id": "N41", "label": "接线自检步指向另一个自测(自检根本不跑)", "op": "step_set", "target": "wiring", "key": "run", "value": "python3 packaging/check_gpu_shot.py --self-test", "expect": "2 自测步跑的是对脚本且是单条命令"},
{"id": "N23", "label": "接线自检步后加 || echo 先忽略(守卫把自己关了)", "op": "run_append", "target": "wiring", "value": " || echo \"自检没过(先忽略)\"", "expect": "2 自测步跑的是对脚本且是单条命令"},
{"id": "N11", "label": "删掉 push / pull_request 触发(main、PR 侧停摆)", "op": "trigger_set", "value": ["workflow_dispatch"], "expect": "10 触发面(push/pull_request/workflow_call)"},
{"id": "N12", "label": "删掉 workflow_call 触发(dev 线 uses 会加载失败)", "op": "trigger_set", "value": {"push": {"branches": ["main"]}, "pull_request": null, "workflow_dispatch": null}, "expect": "10 触发面(push/pull_request/workflow_call)"}
]
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""`packaging/check_gpu_shot.py --self-test` 的执行器(M249):合成日志 + 合成 PNG + 向量比对。
为什么单独一份而不是塞进判据脚本:那一份要守 ≤400 行硬线,而这里全是「按真实行格式手拼日志、
现造 PNG、逐条对 `expect_fail`」的**测试脚手架**,与判据逻辑无关。数据另外放
`gpu_shot_vectors.json`(同 `guard_vectors.json` 的做法),被测的判据函数由调用方**以参数传入**
——本文件不反向 import `check_gpu_shot`,免得循环导入。
一颗诚实的钉子:本文件的样本是**按今天的日志格式**手拼的,所以它守**判据**,不守**格式漂移**。
产品真换了 Debug 形状时,靠的是判据里「取不到 device_type 就算失败」这类保守口径(见 doc §3)。
"""
from __future__ import annotations
import json
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # `check_screenshot` 在 packaging/
from check_screenshot import encode_png # noqa: E402
#: 向量数据。`log` 是 `synth_log` 的关键字覆盖(缺省键取函数默认值,`null` = 该行不出现)。
VECTORS_PATH = Path(__file__).resolve().parent / "gpu_shot_vectors.json"
SELFTEST_SIZE = (200, 120)
def synth_log(
*,
renderer: str | None = "wgpu",
device_type: str | None = "DiscreteGpu",
backend: str | None = "final=wgpu result=started",
size: tuple[int, int] | None = None,
no_size_line: bool = False,
multiline_selected: bool = False,
empty_selected: bool = False,
extra_shot_renderer: str | None = None,
) -> str:
"""按 M246 真实日志的**行格式**拼一份最小日志(传 `None` = 该行故意不出现)。"""
lines = [
"[probe] renderer = wgpu reason=forced-gpu 兜底=tiny-skia(失败会先打印具名错误)",
# 真实日志里永远有这一行提示文本,且它含 `Selected: AdapterInfo` 字样 —— 留着它,
# 「只有提示、没有真 Selected 行」的向量才是在测真东西。
"[probe] 取证:出现 `[gpu] iced_wgpu: …Selected: AdapterInfo…` ⇒ GPU 真生效;出现 `[gpu] wgpu 初始化失败` ⇒ 已降级 CPU",
]
if empty_selected: # `Selected: AdapterInfo {` 之后再没有内容(字段全丢)
lines.append("[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo {")
elif device_type is not None:
fields = [
' name: "AMD Radeon RX 6600 XT",',
f" device_type: {device_type},",
' backend: Vulkan,',
]
if multiline_selected: # pretty-print Debug:字段不在 `{` 那一行(P2-1 的形状)
lines.append("[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo {")
lines.extend(fields)
lines.append("}")
else:
lines.append("[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo {" + "".join(fields) + " }")
if not no_size_line:
dims = size or SELFTEST_SIZE
lines.append(f"[shot] {dims[0]}x{dims[1]} scale=1 -> selftest.png")
for value in (renderer, extra_shot_renderer):
if value is not None:
lines.append(f'[shot] screen=gallery theme=light renderer={value} route=Detail("survival")')
if backend is not None:
lines.append(f"[backend] {backend} elapsed=1000ms evidence=selftest")
return "\n".join(lines) + "\n"
def synth_png(kind: str, size: tuple[int, int]) -> bytes:
"""`content` = 有内容的合成帧(渐变 + 8px 棋盘格);`flat` = 全白。"""
width, height = size
def content_row(y: int) -> bytes:
return b"".join(
bytes((x * 255 // max(width - 1, 1), y * 255 // max(height - 1, 1), 40))
if ((x // 8) + (y // 8)) % 2 == 0
else b"\x14\x14\x14"
for x in range(width)
)
if kind == "flat":
return encode_png(width, height, lambda _y: b"\xff" * (width * 3))
return encode_png(width, height, content_row)
def run_vector(root: Path, index: int, case: dict, default_size: tuple[int, int], judges: tuple) -> list[str]:
"""跑一条向量,返回它实际红的判据清单。`judges` = (judge_evidence, judge_refuse)。"""
judge_evidence, judge_refuse = judges
overrides = {key: (tuple(value) if isinstance(value, list) else value) for key, value in case.get("log", {}).items()}
log_path = root / f"case{index:02d}.log"
log_path.write_text(synth_log(**{"size": default_size, **overrides}), encoding="utf-8")
png_path = root / f"case{index:02d}.png"
png_size = tuple(case.get("png_size", default_size))
if case.get("png") == "truncated":
png_path.write_bytes(synth_png("content", png_size)[:200])
elif case.get("png") is not None:
png_path.write_bytes(synth_png(case["png"], png_size))
report = judge_refuse(log_path, png_path) if case.get("mode") == "refuse" else judge_evidence(log_path, png_path)
return report.problems()
def run_selftest(judge_evidence, judge_refuse) -> int:
"""跑 `gpu_shot_vectors.json` 的全部向量,逐条对 `expect_fail`(多一条少一条都算失败)。"""
data = json.loads(VECTORS_PATH.read_text(encoding="utf-8"))
default_size = tuple(data["size"])
failures = 0
with tempfile.TemporaryDirectory(prefix="ull-gpu-shot-") as tmp:
root = Path(tmp)
for index, case in enumerate(data["cases"], start=1):
got = run_vector(root, index, case, default_size, (judge_evidence, judge_refuse))
want = case["expect_fail"]
if got != want:
print(f"::error::自测失败:{case['label']} 期望失败 {want},实际 {got}", file=sys.stderr)
failures += 1
else:
print(f"OK: {case['label']} → {'红 ' + ' / '.join(got) if got else '绿'}")
red = sum(1 for case in data["cases"] if case["expect_fail"])
if failures:
return 1
print(f"OK: GPU 截图证据判定自测通过({len(data['cases'])} 条向量:红 {red} / 绿 {len(data['cases']) - red})")
return 0
+120
View File
@@ -0,0 +1,120 @@
{
"note": "M249 check_gpu_shot.py --self-test 的向量。log = synth_log 的关键字覆盖(缺省键取函数默认值;null = 该行不出现;no_size_line = 只省掉 [shot] WxH 那一行;multiline_selected / empty_selected = `Selected: AdapterInfo {` 的字段换了形状;extra_shot_renderer = 再追加一行异种 renderer 的 [shot] 行);png = content/flat/truncated/null(null = 不写文件);mode = evidence/refuse;expect_fail = **期望的失败判据清单(逐条对,多一条少一条都算自测失败)**——判据被关掉或被别的东西掩盖,都在这里现形。PNG 由 check_screenshot.encode_png 现造,不进仓库。",
"size": [200, 120],
"cases": [
{
"label": "基线:wgpu + DiscreteGpu + 有内容 PNG",
"png": "content",
"expect_fail": []
},
{
"label": "基线:IntegratedGpu 也算硬件(与 probe.rs 同口径)",
"log": { "device_type": "IntegratedGpu" },
"png": "content",
"expect_fail": []
},
{
"label": "变异靶子:renderer=wgpu 但 Selected 是 Cpu(WARP)",
"log": { "device_type": "Cpu" },
"png": "content",
"expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"]
},
{
"label": "变异靶子:Selected 是 Other(Gl 兼容层)",
"log": { "device_type": "Other" },
"png": "content",
"expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"]
},
{
"label": "变异靶子:Selected 换成多行 Debug(字段不在 { 那一行)",
"log": { "multiline_selected": true },
"png": "content",
"expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"]
},
{
"label": "变异靶子:`Selected: AdapterInfo {` 空体(字段全丢)",
"log": { "device_type": null, "empty_selected": true },
"png": "content",
"expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"]
},
{
"label": "变异靶子:删掉 [shot] renderer 行(只剩 Selected + backend)",
"log": { "renderer": null },
"png": "content",
"expect_fail": ["截图行 renderer=wgpu"]
},
{
"label": "变异靶子:日志里混入第二种 renderer(CPU 那次 >> 拼进来)",
"log": { "extra_shot_renderer": "tiny-skia" },
"png": "content",
"expect_fail": ["截图行 renderer=wgpu"]
},
{
"label": "回归:CPU 档出的图(renderer=tiny-skia)",
"log": { "renderer": "tiny-skia", "device_type": null, "backend": "final=tiny-skia result=started" },
"png": "content",
"expect_fail": ["截图行 renderer=wgpu", "Selected: AdapterInfo 的 device_type 不是 Cpu/Other", "后端结论行 final=wgpu result=started"]
},
{
"label": "回归:没有 Selected 行(wgpu 没建成设备)",
"log": { "device_type": null },
"png": "content",
"expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"]
},
{
"label": "回归:后端结论行是失败",
"log": { "backend": "final=none result=failed via=refuse" },
"png": "content",
"expect_fail": ["后端结论行 final=wgpu result=started"]
},
{
"label": "回归:完全空白帧(全白 PNG)",
"png": "flat",
"expect_fail": ["PNG 不是空白帧"]
},
{
"label": "回归:PNG 尺寸与日志 [shot] 不符",
"png": "content",
"png_size": [640, 480],
"expect_fail": ["PNG 宽高 = 日志 [shot] 的宽高"]
},
{
"label": "回归:PNG 根本不存在",
"png": null,
"expect_fail": ["PNG 能解码且非空", "PNG 宽高 = 日志 [shot] 的宽高", "PNG 不是空白帧"]
},
{
"label": "回归:PNG 半截(签名对、字节不够)",
"png": "truncated",
"expect_fail": ["PNG 能解码且非空", "PNG 宽高 = 日志 [shot] 的宽高", "PNG 不是空白帧"]
},
{
"label": "负例基线:via=refuse + 无出图行 + 无 PNG",
"log": { "renderer": null, "device_type": null, "no_size_line": true, "backend": "final=none result=failed via=refuse" },
"png": null,
"mode": "refuse",
"expect_fail": []
},
{
"label": "负例回归:拒绝日志旁边却躺着一张 PNG",
"log": { "renderer": null, "device_type": null, "no_size_line": true, "backend": "final=none result=failed via=refuse" },
"png": "content",
"mode": "refuse",
"expect_fail": ["目标 PNG 不存在(拒绝出图就该一张都没有)"]
},
{
"label": "负例回归:CPU 档日志不是「拒绝」(via 缺)",
"log": { "renderer": "tiny-skia", "device_type": null, "backend": "final=tiny-skia result=started" },
"png": null,
"mode": "refuse",
"expect_fail": ["后端结论行 via=refuse", "没有出图行([shot] 尺寸 + renderer 都不该有)"]
},
{
"label": "负例回归:final=… via=refuse 却仍有出图行(自相矛盾)",
"log": { "backend": "final=none result=failed via=refuse" },
"png": "content",
"mode": "refuse",
"expect_fail": ["没有出图行([shot] 尺寸 + renderer 都不该有)", "目标 PNG 不存在(拒绝出图就该一张都没有)"]
}
]
}
+236
View File
@@ -0,0 +1,236 @@
{
"baseline": {
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n",
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo build --release\n",
".github/workflows/release-publish.yml": "jobs:\n update-e2e:\n steps:\n - run: echo publish\n",
"packaging/make_artifacts.py": "def build():\n return 'cargo build --release'\n",
"packaging/tests/build.sh": "#!/bin/sh\ncargo build --release\n",
"Makefile": "build:\n\tcargo build --release\n",
"Cargo.toml": "[workspace]\nmembers = []\n",
"crates/core/Cargo.toml": "[features]\nupdate-test-source = []\n# 注释里提到它不算违规\n"
},
"vectors": [
{
"label": "allowlist:ci.yml 的 update-e2e 一步",
"expect": "green",
"files": {}
},
{
"label": "allowlist:同一步放进 release-build.yml",
"expect": "red",
"files": {
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n"
}
},
{
"label": "allowlist:步骤名改成 update-e2e-r2",
"expect": "red",
"files": {
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e-r2\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n"
}
},
{
"label": "allowlist:少了 --test update_e2e_test",
"expect": "red",
"files": {
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_tamper_test\n"
}
},
{
"label": "allowlist:发布工作流里另起一步",
"expect": "red",
"files": {
".github/workflows/release-publish.yml": "jobs:\n update-e2e:\n steps:\n - name: leak\n - run: cargo build --release --features update-test-source\n"
}
},
{
"label": "allowlist:ci.yml 里另一个具名步骤",
"expect": "red",
"files": {
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - name: another\n - run: cargo build --release --features update-test-source\n"
}
},
{
"label": "P2-1 ①:末尾追加无名步骤",
"expect": "red",
"files": {
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - run: cargo build --release --features update-test-source\n"
}
},
{
"label": "P2-1 ②:末尾追加无名步骤的整个 job",
"expect": "red",
"files": {
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n release-from-ci:\n steps:\n - run: cargo build --release --features update-test-source\n"
}
},
{
"label": "P2-1 ③:flow 风格步骤",
"expect": "red",
"files": {
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - {name: release-build, run: \"cargo build --features update-test-source\"}\n"
}
},
{
"label": "P2-1 ④:步骤写在 jobs: 之前(孤儿行)",
"expect": "red",
"files": {
".github/workflows/ci.yml": " - run: cargo build --release --features update-test-source\njobs:\n update-e2e:\n steps:\n - run: echo hi\n"
}
},
{
"label": "P2-1 ⑤:`run: |` 块里伪造 `name: update-e2e`",
"expect": "red",
"files": {
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - run: |\n echo 'name: update-e2e'\n cargo build --release --features update-test-source\n"
}
},
{
"label": "P2-2 ①:`#` 在引号内、真命令在后面",
"expect": "red",
"files": {
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: echo \"#\"; cargo build --features update-test-source\n"
}
},
{
"label": "P2-2 ②:shell 变量拼接(字面量仍在)",
"expect": "red",
"files": {
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: FEAT=\"update-test-source\"; cargo build --features \"$FEAT\"\n"
}
},
{
"label": "Cargo ①:default 把 feature 带上",
"expect": "red",
"files": {
"crates/core/Cargo.toml": "[features]\nupdate-test-source = []\n# 注释里提到它不算违规\ndefault = [\"update-test-source\"]\n"
}
},
{
"label": "Cargo ②:别的 feature 依赖它",
"expect": "red",
"files": {
"crates/core/Cargo.toml": "[features]\nupdate-test-source = []\n# 注释里提到它不算违规\nrelease = [\"update-test-source\"]\n"
}
},
{
"label": "误杀 ①:注释里提到该 feature",
"expect": "green",
"files": {
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n # 这里不许加 update-test-source\n - run: cargo build --release\n"
}
},
{
"label": "误杀 ②:允许的那一步把 `run:` 写在 `name:` 之前(键序无关)",
"expect": "green",
"files": {
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo test --release --features update-test-source --test update_e2e_test\n name: update-e2e\n"
}
},
{
"label": "误杀 ③:`cargo hack check --all-targets`(不打开任何 feature)",
"expect": "green",
"files": {
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo hack check --all-targets\n"
}
},
{
"label": "误杀 ④:Cargo 多行 feature 声明",
"expect": "green",
"files": {
"crates/core/Cargo.toml": "[features]\nupdate-test-source = [\n \"unlucky-core/update-test-source\",\n \"unlucky-cli/update-test-source\",\n]\n"
}
},
{
"label": "P2-2:cargo hack --feature-powerset",
"expect": "red",
"files": {
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo hack --feature-powerset build\n"
}
},
{
"label": "P2-2:cargo hack --each-feature",
"expect": "red",
"files": {
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo hack --each-feature build\n"
}
},
{
"label": "P2-2:cargo hack --all",
"expect": "red",
"files": {
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo hack --all build\n"
}
},
{
"label": "P2-2 扫描面:packaging/tests/build.sh",
"expect": "red",
"files": {
"packaging/tests/build.sh": "#!/bin/sh\ncargo build --features update-test-source\n"
}
},
{
"label": "P2-2 扫描面:.github/actions/rebuild/action.yml",
"expect": "red",
"files": {
".github/actions/rebuild/action.yml": "runs:\n using: composite\n steps:\n - run: cargo build --features update-test-source\n shell: bash\n"
}
},
{
"label": "P2-2 扫描面:.github/workflows/nested/extra.yml",
"expect": "red",
"files": {
".github/workflows/nested/extra.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo build --release --features update-test-source\n"
}
},
{
"label": "P2-2 扫描面:Makefile",
"expect": "red",
"files": {
"Makefile": "build:\n\tcargo build --release --features update-test-source\n"
}
},
{
"label": "P2-1 ⑥:`name:` 写在更深缩进的 env 子项里",
"expect": "red",
"files": {
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - run: cargo build --release -p unlucky-cli --features update-test-source\n env:\n T: --test update_e2e_test\n HINT: |\n - name: update-e2e\n"
}
},
{
"label": "P2-1 ⑦:`name:` 写在 `run: |` 块标量里",
"expect": "red",
"files": {
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - run: |\n name: update-e2e\n cargo build --release --features update-test-source --test update_e2e_test\n"
}
},
{
"label": "P2-1 ⑧:同一项里两个不同的 `name:`",
"expect": "red",
"files": {
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n run: cargo build --release --features update-test-source --test update_e2e_test\n name: release-build\n"
}
},
{
"label": "P2-2 ①:.cargo/config.toml 用 `--cfg feature=` 开该 feature",
"expect": "red",
"files": {
".cargo/config.toml": "[build]\nrustflags = [\"--cfg\", 'feature=\"update-test-source\"']\n"
}
},
{
"label": "P2-2 ②:.cargo/config.toml 设 rustc-wrapper",
"expect": "red",
"files": {
".cargo/config.toml": "[build]\nrustc-wrapper = \"./tools/wrapper\"\n"
}
},
{
"label": "误杀 ⑤:普通 .cargo/config.toml(无 feature / 无 wrapper)",
"expect": "green",
"files": {
".cargo/config.toml": "[build]\njobs = 4\n"
}
}
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"channel": "stable",
"releases": [
{
"version": "9.9.9",
"channel": "stable",
"released_at": "2026-01-02T03:04:05Z",
"notes": "golden 样例(自验用,不是真实发布)",
"force": false,
"min_supported": "9.0.0",
"artifacts": [
{
"target": "win-x64",
"format": "portable-exe",
"url": "unlucky-launcher_9.9.9_windows_x64.exe",
"size": 12,
"sha256": "8ac2dbaad20a7b318a75485359b0302d6d2550093b69296cea39db18d4836ef0"
},
{
"target": "linux-x64",
"format": "portable-zip",
"url": "unlucky-launcher_9.9.9_linux_x64.tar.gz",
"size": 12,
"sha256": "dcd549d0571bb74eaee60af5c0bc702714fb0de052094336ab10e1e0c7faea0e"
}
]
}
]
}
+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""`packaging/check_signing_ready.py` 的判据自测(M255):让「三行读数 + 0/1/2 退出码」变成可执行的事实。
为什么它必须存在:这个脚本的全部价值是「说清今天差什么」。它一旦退化成「永远 exit 0」,
它就从**播报**变成了本仓栽过四次的**假绿尺子** —— 而且没人会当场发现(输出看起来一模一样)。
所以这里的第一类用例判的是退出码语义,第二类用例判的是**把退出码改绿之后自测会红**(变异重放)。
| 用例 | 抓什么 |
|---|---|
| `empty_table` | ① 表空 ⇒ `exit 1`、三行都打印、有 fail-closed 句 + 修复行 |
| `pinned_and_key` | ② 副本夹假 `PinnedKey` ⇒ `exit 0`(**不碰真实 `trust.rs`**),且不泄种子 |
| `mutation` A/B | 退出码永远 0 ⇒ 判据①必须红;永远 1 ⇒ 判据②必须红(两向都要) |
| `key_mismatch` / `no_key` / `release_*` | ② 对账、③ 有/无/未知(含 `.sig` 子串假读数)、③ 不参与退出码 |
| `key_unreadable` | `--key` 不存在 / 是目录 ⇒ **2**(不是 1,也不是 traceback —— r1 P2-2) |
| `withheld` | 非 main ref ⇒ ② 写「未判定」+ 原因,**不写「没配」**(r1 P2-1 第三条) |
| `section_refs` | 输出里每个 `§N` 都是 runbook 真实章节号(r1 P2-3) |
| `stray_hint` / `no_seed_leak` / `usage` / `summary` | ① 标记外条目只点名不误报、种子不出现、用法错 = 2、`--line` 写 summary |
接线(不是门禁 / 不被 `uses:` / **ref 门** / 文档路径存在性)在
**`packaging/tests/signing_ready_wiring_selftest.py`** —— 拆成两份只因为全仓 400 行红线。
两份都不覆盖「整个 workflow 被删掉」(守门人住在被守对象旁边,与 `gpu_shot_ci_selftest.py` 同一条边界)。
`DUMMY_SEED` 是**由一句固定文本哈希出来的**假种子(同 `trust.rs` 里 `TEST_ONLY` 那把公开测试钥同一路数),
只用来造读数,签不出任何正式清单;文档与本文件都不含任何真实私钥。
python3 packaging/tests/signing_ready_selftest.py
"""
from __future__ import annotations
import hashlib
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
SCRIPT = ROOT / "packaging/check_signing_ready.py"
RUNBOOK = ROOT / "packaging/docs/signing-runbook.md"
KEY_ENV = "ULL_MANIFEST_SIGNING_KEY"
WITHHELD_ENV = "ULL_SIGNING_KEY_WITHHELD"
FAIL_CLOSED = "这会让已安装用户永远验不过(fail-closed)"
LABELS = ("① 信任表 PRODUCTION", "② 环境签名钥", "③ 最近一次发布痕迹")
sys.path.insert(0, str(ROOT / "packaging"))
from ed25519_ref import public_key # noqa: E402
DUMMY_SEED = hashlib.sha256(b"ull-m255-signing-ready-selftest-not-a-real-key").hexdigest()
DUMMY_PUB = public_key(bytes.fromhex(DUMMY_SEED)).hex()
OTHER_SEED = hashlib.sha256(b"ull-m255-selftest-the-other-dummy-key").hexdigest()
TRUST_TEMPLATE = """\
pub(crate) const PRODUCTION: &[PinnedKey] = &[
// >>> PINNED-KEYS >>>
{body} // <<< PINNED-KEYS <<<
];
"""
def secret_json(seed: str = DUMMY_SEED, key_id: str = "selftest-key") -> str:
return '{"key_id": "%s", "seed_hex": "%s"}' % (key_id, seed)
def write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8", newline="\n")
return path
def run(
args: list[str],
env_key: str | None,
summary: Path | None = None,
script: Path = SCRIPT,
withheld: str | None = None,
) -> tuple[int, str]:
"""跑一次脚本,返回 `(退出码, stdout+stderr)`。
`env_key` = 塞进 `KEY_ENV` 的内容(`None` = 不设);`withheld` = 塞进 `WITHHELD_ENV` 的内容。
两个 key 都先**从父环境剔除**再按参数注入:否则本机残留的变量会让用例假绿。
"""
env = {k: v for k, v in os.environ.items() if k not in (KEY_ENV, WITHHELD_ENV)}
env.pop("GITHUB_STEP_SUMMARY", None)
# 子进程与父进程的编码显式对齐成 UTF-8:本机 Windows 默认 gbk,中文读数会 decode 失败,
# 而 CI(ubuntu)本来就是 UTF-8。这条不是洁癖 —— 编码不一致会让整份自测在 Windows 上假红/假绿。
env["PYTHONIOENCODING"] = "utf-8"
if env_key is not None:
env[KEY_ENV] = env_key
if withheld is not None:
env[WITHHELD_ENV] = withheld
if summary is not None:
env["GITHUB_STEP_SUMMARY"] = str(summary)
done = subprocess.run(
[sys.executable, str(script), *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=env,
cwd=str(ROOT),
check=False,
)
return done.returncode, done.stdout + done.stderr
def case_empty_table(work: Path, failures: list[str], script: Path = SCRIPT) -> None:
"""① 今天的状态:空表 ⇒ exit 1,三行都要打印,且必须点明 fail-closed 后果与修复命令。
`script` 可换:变异用例把**同一组断言**原样套在「退出码永远 0」的副本上,断言集合必须报红。
"""
trust = write(work / "empty/trust.rs", TRUST_TEMPLATE.format(body=""))
rc, out = run(["--trust", str(trust)], None, script=script)
if rc != 1:
failures.append(f"判据①:空表应 exit 1,实际 {rc}\n{out}")
missing = [label for label in LABELS if label not in out]
if missing:
failures.append(f"判据①:空表时没打印这三行中的 {missing}\n{out}")
if FAIL_CLOSED not in out:
failures.append(f"判据①:未就绪时必须写明「{FAIL_CLOSED}」(后果,不是术语)\n{out}")
if "修复:" not in out:
failures.append(f"判据①:未就绪时必须给一行修复命令\n{out}")
def case_pinned_and_key(work: Path, failures: list[str], script: Path = SCRIPT) -> None:
"""② 副本里夹一条假 PinnedKey + 环境里是同一把钥 ⇒ exit 0(真实 trust.rs 一个字节都不碰)。"""
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "pinned/trust.rs", TRUST_TEMPLATE.format(body=body))
rc, out = run(["--trust", str(trust)], secret_json(), script=script)
if rc != 0:
failures.append(f"判据②:表非空 + 钥同源应 exit 0,实际 {rc}\n{out}")
missing = [label for label in LABELS if label not in out]
if missing:
failures.append(f"判据②:就绪时也要打印三行,缺 {missing}\n{out}")
if "就绪" not in out:
failures.append(f"判据②:就绪时必须明说「就绪」\n{out}")
if FAIL_CLOSED in out:
failures.append(f"判据②:就绪时不该出现 fail-closed 那段警告\n{out}")
# 不泄密:种子的十六进制在输出里一次都不许出现(公钥可以出现,它是公开物)。
if DUMMY_SEED in out:
failures.append(f"判据②:输出里出现了**种子**(私钥)—— 这是泄密\n{out}")
def case_key_mismatch(work: Path, failures: list[str]) -> None:
"""② 的对账:表里钉的是 A,环境里是 B ⇒ 未就绪(发布时 --check-pinned-keyring 会红)。"""
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "mismatch/trust.rs", TRUST_TEMPLATE.format(body=body))
rc, out = run(["--trust", str(trust)], secret_json(OTHER_SEED, "other-key"))
if rc != 1:
failures.append(f"②对账:钥不在表里应 exit 1,实际 {rc}\n{out}")
if "不在" not in out:
failures.append(f"②对账:必须明说这把钥不在信任表里\n{out}")
if DUMMY_SEED in out or OTHER_SEED in out:
failures.append(f"②对账:输出里出现了种子\n{out}")
def case_no_key(work: Path, failures: list[str]) -> None:
"""② 没有钥:即便表是好的,也算未就绪(签不出任何清单)。"""
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "nokey/trust.rs", TRUST_TEMPLATE.format(body=body))
rc, out = run(["--trust", str(trust)], None)
if rc != 1:
failures.append(f"②无钥:应 exit 1,实际 {rc}\n{out}")
if KEY_ENV not in out:
failures.append(f"②无钥:必须点名环境变量 {KEY_ENV}(换个人也知道该配什么)\n{out}")
def case_release_evidence(work: Path, failures: list[str]) -> None:
"""③ 只取证:有留档 / 有清单没签名 / 什么都没给 = 未知;且它不影响退出码。"""
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "release/trust.rs", TRUST_TEMPLATE.format(body=body))
signed = work / "release/signed"
write(signed / "manifest.json", '{"channel": "stable", "releases": []}\n')
write(signed / "manifest.json.sig", '{"format": 1, "signatures": []}\n')
rc, out = run(["--trust", str(trust), "--dist", str(signed)], secret_json())
if rc != 0 or "有 ——" not in out:
failures.append(f"③有:留档齐全时应写「有」且退出码仍是 0,实际 rc={rc}\n{out}")
bare = work / "release/bare"
write(bare / "manifest.json", '{"channel": "stable", "releases": []}\n')
rc, out = run(["--trust", str(trust), "--dist", str(bare)], secret_json())
if "没有" not in out:
failures.append(f"③无:只有 manifest.json 时必须点明「没有 .sig」\n{out}")
nowhere = work / "release/absent"
rc, out = run(["--trust", str(trust), "--dist", str(nowhere)], secret_json())
if "未知" not in out:
failures.append(f"③未知:取不到证据时必须写「未知」,不许写成「无」(本地没留档 ≠ 上次没签)\n{out}")
if rc != 0:
failures.append(f"③未知:③ 不参与就绪判定,退出码应仍是 0,实际 {rc}\n{out}")
def case_usage(work: Path, failures: list[str]) -> None:
"""用法错 = 2:参数写错、标记丢了(脚本形不成判断)。绝不能混进 1(那是「未就绪」的读数)。"""
rc, out = run(["--nonsense"], None)
if rc != 2:
failures.append(f"用法错:未知参数应 exit 2,实际 {rc}\n{out}")
broken = write(work / "broken/trust.rs", "pub(crate) const PRODUCTION: &[PinnedKey] = &[];\n")
rc, out = run(["--trust", str(broken)], None)
if rc != 2:
failures.append(f"用法错:标记缺失应 exit 2(不是 1),实际 {rc}\n{out}")
rc, out = run(["--trust", str(work / "no-such-dir/trust.rs")], None)
if rc != 2:
failures.append(f"用法错:trust.rs 不存在应 exit 2,实际 {rc}\n{out}")
def case_summary(work: Path, failures: list[str]) -> None:
"""`--line`:CI 用,把三行搬进 $GITHUB_STEP_SUMMARY;且必须写明「不是门禁」。"""
trust = write(work / "summary/trust.rs", TRUST_TEMPLATE.format(body=""))
summary = work / "summary-step.md"
rc, out = run(["--trust", str(trust), "--line"], None, summary=summary)
if rc != 1:
failures.append(f"summary:空表仍应 exit 1,实际 {rc}\n{out}")
if not summary.is_file():
failures.append("summary:给了 $GITHUB_STEP_SUMMARY 却没写文件")
return
text = summary.read_text(encoding="utf-8")
missing = [label for label in LABELS if label not in text]
if missing:
failures.append(f"summary:三行没全部进 summary,缺 {missing}\n{text}")
if "不代表签名就绪" not in text:
failures.append(f"summary:必须写清「绿色不代表签名就绪」(播报 ≠ 门禁)\n{text}")
TARGET_LINE = " return 0 if ready else 1\n"
def mutant(work: Path, name: str, replacement: str) -> Path | None:
"""造一份「只改了退出码那一行」的副本。副本自成一体(模块一起拷):脚本按 `__file__` 上一级找
`packaging/`,不靠 PYTHONPATH(实测本机 3.12 根本不把 PYTHONPATH 放进 sys.path)。"""
source = SCRIPT.read_text(encoding="utf-8")
if source.count(TARGET_LINE) != 1:
return None
root = work / name
path = write(root / "packaging/check_signing_ready_mutated.py", source.replace(TARGET_LINE, replacement))
for module in ("sign_manifest.py", "ed25519_ref.py"):
write(root / f"packaging/{module}", (ROOT / f"packaging/{module}").read_text(encoding="utf-8"))
write(root / "trust.rs", TRUST_TEMPLATE.format(body=""))
return path
def case_stray_hint(work: Path, failures: list[str]) -> None:
"""① 的「标记之外还有条目」提示:只**点名**,不替人断言「贴错了」。
实测抓到的误报:真实 `trust.rs` 里 `TEST_ONLY` 那一条**本来就在标记之外**(那是它的正确位置),
上一版措辞「另有 N 条写在标记之外(贴错位置,等于没钉)」会对今天的状态喊狼来了。
"""
outside = 'pub(crate) const TEST_ONLY: &[PinnedKey] = &[PinnedKey { key_id: "test-only-key", public_key_hex: "%s" }];\n'
trust = write(
work / "stray/trust.rs",
outside % ("ab" * 32) + TRUST_TEMPLATE.format(body=""),
)
rc, out = run(["--trust", str(trust)], None)
if rc != 1:
failures.append(f"①提示:标记外有条目时表仍是空的 ⇒ 应 exit 1,实际 {rc}\n{out}")
if "test-only-key" not in out:
failures.append(f"①提示:标记之外的条目必须**点名**(否则人不知道贴漏了哪一条)\n{out}")
if "TEST_ONLY" not in out:
failures.append(f"①提示:必须说清 `TEST_ONLY` 本来就在标记之外(否则这就是一句误报)\n{out}")
def case_key_unreadable(work: Path, failures: list[str]) -> None:
"""② `--key` 指向读不了的路径 ⇒ **2**(用法错),不是 1、也不是 traceback(r1 P2-2)。
`read_text` 曾在 `main()` 的 try 之外 ⇒ `FileNotFoundError` 直接抛成 rc=1,与「未就绪」同码。
脚本的全部价值是「退出码可信」:1 和 2 混了,「我路径写错了」会被读成「签名没配好」。
"""
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "badkey/trust.rs", TRUST_TEMPLATE.format(body=body))
directory = work / "badkey/a-directory"
directory.mkdir(parents=True, exist_ok=True)
for label, path in (
("不存在", work / "badkey/no-such.key"),
("是目录", directory),
):
rc, out = run(["--trust", str(trust), "--key", str(path)], None)
if rc != 2:
failures.append(f"--key({label})应 exit 2,实际 {rc}\n{out}")
if "Traceback" in out:
failures.append(f"--key({label})不许抛未捕获异常(traceback 会盖掉一行说明)\n{out}")
if "用法错" not in out:
failures.append(f"--key({label})必须说清这是用法错,不是「未就绪」\n{out}")
if LABELS[0] in out or LABELS[1] in out:
failures.append(f"--key({label})在形不成判断时不该打印三行读数(会被读成「读数」)\n{out}")
def case_withheld(work: Path, failures: list[str]) -> None:
"""② 非 main ref:写「未判定 + 原因」,**不写「没配」**(r1 P2-1 第三条)。
同一份空钥输入,有没有 `ULL_SIGNING_KEY_WITHHELD` 是两件事:「策略上不注入」不是「仓库没配置」。
"""
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "withheld/trust.rs", TRUST_TEMPLATE.format(body=body))
reason = "非 main ref(按设计不注入 Secret;要读这一行请对 main 跑)"
rc, out = run(["--trust", str(trust)], None, withheld=reason)
if rc != 1:
failures.append(f"未判定:这一趟没钥可读 ⇒ 不许声称就绪(应 exit 1),实际 {rc}\n{out}")
if "未判定" not in out or reason not in out:
failures.append(f"未判定:② 必须写「未判定」+ 原因串(否则会被读成「没配」)\n{out}")
if "未注入" not in out:
failures.append(f"未判定:② 必须写出「未注入」这个动作(「策略不注入」与「没配置」是两件事)\n{out}")
if "未设置" in out:
failures.append(f"未判定:非 main ref 上不许出现「未设置」(那是「没配」的措辞)\n{out}")
# 反方向:没有 withheld 时照样得说「未设置/为空」,别把两档并成一档。
rc, out = run(["--trust", str(trust)], None)
if "未设置" not in out:
failures.append(f"未判定:没有 withheld 时仍应报「未设置/为空」(两档不许合并)\n{out}")
def case_section_refs(work: Path, failures: list[str]) -> None:
"""输出里出现的每个 `§N` 都必须是 runbook **真实存在**的章节号(r1 P2-3)。
上一版把「贴 PinnedKey」指到 §3(那里讲发布时的执行顺序)、把「发布后回验」指到 §4(那里是已知坑)。
本文件的交付物就是「一行可照抄的指针」,所以指针要机器校验。
"""
sections = set(re.findall(r"^##\s*(\d+)\.", RUNBOOK.read_text(encoding="utf-8"), re.MULTILINE))
if not sections:
failures.append("指针:runbook 里一个 `## N.` 标题都没解析到(章节号格式变了?)")
return
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "pointer/trust.rs", TRUST_TEMPLATE.format(body=body))
empty = write(work / "pointer/empty.rs", TRUST_TEMPLATE.format(body=""))
seen: set[str] = set()
for args, key in (
(["--trust", str(trust)], secret_json()), # 就绪:结论指向步⑤ / §5
(["--trust", str(trust)], None), # ② 没钥:指向步②
(["--trust", str(empty)], None), # ① 空表:修复行指向步①–③
):
seen |= set(re.findall(r"§(\d+)", run(args, key)[1]))
if not seen:
failures.append("指针:一条 `§N` 都没从输出里抓到(用例失效了)")
unknown = sorted(seen - sections)
if unknown:
failures.append(f"指针:输出里指向了 runbook 没有的章节 {unknown}(真实章节:{sorted(sections)})")
def case_mutation(work: Path, failures: list[str]) -> None:
"""变异重放:把被测对象改坏,**同一组断言原样套上去必须报红**(两个方向)。
(接线/ref 门那一侧的变异在 `signing_ready_wiring_selftest.py`。)"""
# ── 变异 A:退出码永远 0(「未就绪」伪装成「就绪」)⇒ 判据① 必须红 ──
always_zero = mutant(work, "mutant-zero", " return 0\n")
if always_zero is None:
failures.append(f"变异 A:{SCRIPT.name} 里找不到唯一的 `{TARGET_LINE.strip()}`,变异无处下手(脚本形状变了就更新这里)")
else:
trust = work / "mutant-zero/trust.rs"
probe: list[str] = []
case_empty_table(work / "mutant-zero", probe, script=always_zero)
# 副本必须真的「跑得起来、只有退出码变了」:崩溃(rc=2)也会让 probe 非空,那就变成假绿了。
rc, out = run(["--trust", str(trust)], None, script=always_zero)
if rc != 0:
failures.append(f"变异 A:副本在空表上应返回 0(这就是变异本身),实际 {rc}\n{out}")
missing = [label for label in LABELS if label not in out]
if missing:
failures.append(f"变异 A:副本连三行读数都没打印(缺 {missing}),它不是在测退出码那一条\n{out}")
if not probe:
failures.append(f"变异 A:「退出码永远 0」的副本竟然让判据①全过 —— 断言抓不住改绿(副本空表返回 {rc})")
elif not any("exit 1" in item for item in probe):
failures.append(f"变异 A:副本被判红,但不是因为退出码那一条 —— 变异用例没对准判据①:{probe}")
# ── 变异 B:退出码永远 1(「就绪」伪装成「未就绪」)⇒ 判据② 必须红 ──
# 反向也要试:只测一个方向的话,「就绪的正路根本不存在」这种回归仍然全绿。
always_one = mutant(work, "mutant-one", " return 1\n")
if always_one is None:
failures.append(f"变异 B:{SCRIPT.name} 里找不到唯一的 `{TARGET_LINE.strip()}`")
else:
probe = []
case_pinned_and_key(work / "mutant-one", probe, script=always_one)
if not probe:
failures.append("变异 B:「退出码永远 1」的副本竟然让判据②全过 —— 就绪的正路没被断言(假绿的反面)")
elif not any("exit 0" in item for item in probe):
failures.append(f"变异 B:副本被判红,但不是因为退出码那一条 —— 变异用例没对准判据②:{probe}")
def main() -> int:
failures: list[str] = []
with tempfile.TemporaryDirectory(prefix="ull-signing-ready-") as temp:
work = Path(temp)
case_empty_table(work, failures)
case_pinned_and_key(work, failures)
case_key_mismatch(work, failures)
case_no_key(work, failures)
case_release_evidence(work, failures)
case_stray_hint(work, failures)
case_key_unreadable(work, failures)
case_withheld(work, failures)
case_section_refs(work, failures)
case_usage(work, failures)
case_summary(work, failures)
case_mutation(work, failures)
for line in failures:
print(f"FAIL {line}", file=sys.stderr)
print("signing-ready selftest:", "ok" if not failures else f"{len(failures)} 处失败")
return 0 if not failures else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""`signing-status.yml` 的**接线**自测(M255 r1 从 `signing_ready_selftest.py` 分出)。
拆成两份的唯一原因是全仓 400 行红线(`guards.yml` 的 `file-size`):判据本体(三行读数 / `0/1/2` /
两个方向的退出码变异)留在 `signing_ready_selftest.py`,这里只管**接线与文案里的指针**。
钉住的判据(每条都对应一次真实会咬人的回归):
| 判据 | 抓什么 |
|---|---|
| 不是门禁 | 没有别的 workflow `uses:` 它;全文无 error 级注解;就绪度那步抓 `rc=$?` 后 `exit 0`;文件头两句口径都在 |
| **生产种子只在 main 注入** | 注入行必须是 `github.ref == 'refs/heads/main' && secrets.<KEY_ENV>` 这个条件式;`on:` 块里**不许出现 `pull_request`**;非 main 另有 `ULL_SIGNING_KEY_WITHHELD` 说明原因(于是 ② 读「未判定」而不是「没配」) |
| 文档指针真的存在 | `signing-runbook.md` / `signing.md` 里出现的 `packaging/…`、`crates/…`、`.github/…` 路径必须真在仓库里(r1 P2-3:`signing-ready.yml` 那个文件名根本不存在) |
| 变异必须红 | ①去掉 ref 门 ②加回 `pull_request` ③把文档里一个路径改坏 —— 三处各自重放,**对应用例必须报红** |
为什么要 ref 门(这是本文件存在的最重要理由):生产签名种子能给**全量已安装客户端**签出被信任的清单
(自更新链 = 用户端 RCE)。仓库内分支的 PR 与任意分支的 `workflow_dispatch` 都**拿得到**仓库 Secret
(只有 fork / Dependabot 拿不到),而 CI 会在**那个 PR 自己的代码**上执行 checker。既有设计把种子锁在
tag ref 后面(`release.yml` 的 `if: … github.ref_type == 'tag'`),播报文件不许在旁边开一条新路。
python3 packaging/tests/signing_ready_wiring_selftest.py
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
WORKFLOW_DIR = ROOT / ".github/workflows"
WORKFLOW = WORKFLOW_DIR / "signing-status.yml"
RUNBOOK = ROOT / "packaging/docs/signing-runbook.md"
SIGNING_MD = ROOT / "packaging/docs/signing.md"
sys.path.insert(0, str(ROOT / "packaging"))
# 环境变量名从 checker 取,不写第二份字面量:两处漂开时,「ref 门注入了 A、checker 读 B」会静默失效。
from check_signing_ready import KEY_ENV, WITHHELD_ENV # noqa: E402
MAIN_REF = "refs/heads/main"
GATE = f"github.ref == '{MAIN_REF}' && secrets.{KEY_ENV}"
#: 文档里允许出现的「路径」形状:仓库根的四个前缀(相对路径如 `docs/x.md` 不在此列,避免误判)。
PATH_RE = re.compile(r"(?:packaging|crates|\.github|\.cargo)/[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)*")
def on_block(text: str) -> str:
"""取 `on:` 那一段(到下一个顶格键为止),并**去掉注释行**——注释里会正当地提到 `pull_request`。"""
lines = text.splitlines()
start = next((i for i, line in enumerate(lines) if re.match(r"^on:\s*$", line)), None)
if start is None:
return ""
body: list[str] = []
for line in lines[start + 1 :]: # 跳过 `on:` 那一行本身(它顶格,会让下面的 break 立刻触发)
if line.startswith(" ") or not line:
if not line.strip().startswith("#"):
body.append(line)
continue
break
return "\n".join(body)
def check_workflow(text: str) -> list[str]:
"""返回该 workflow 文本没过的接线判据(空 = 通过)。变异用例把同一函数套在改坏的副本上。"""
problems: list[str] = []
for phrase in ("播报,不是门禁", "不应被设为 required"):
if phrase not in text:
problems.append(f"文件头必须写清「{phrase}」(不阻断要是可核的事实,不是靠没接线)")
if "::error::" in text:
problems.append("播报 workflow 里不许出现 error 级注解(会让人以为构建挂了,与播报定位冲突)")
if "rc=$?" not in text or "exit 0" not in text:
problems.append("就绪度那一步必须抓 `rc=$?` 后 `exit 0`(未就绪不许让 job 变红)")
# ── 安全口径:生产签名种子只在 main 注入 ──
if GATE not in text:
problems.append(f"注入行必须是 `{GATE} || ''` 这个条件式(去掉 ref 门 = 把种子交给任意分支的代码)")
if f"{WITHHELD_ENV}:" not in text:
problems.append(f"非 main 分支必须给 `{WITHHELD_ENV}` 一个原因串(否则 ② 会报假的「没配」)")
if "pull_request" in on_block(text):
problems.append("`on:` 块里不许出现 `pull_request`(仓库内分支的 PR 拿得到 Secret,而 CI 跑的是 PR 自己的代码)")
return problems
def wired_elsewhere() -> list[str]:
"""扫全部 workflow:有人 `uses:` 本文件 ⇒ 播报变成了别人的必需检查。"""
hits: list[str] = []
for path in sorted(WORKFLOW_DIR.glob("*.y*ml")):
if path.resolve() == WORKFLOW.resolve():
continue
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
if re.match(r"^\s*uses:\s*", line) and WORKFLOW.name in line:
hits.append(f"{path.relative_to(ROOT).as_posix()}:{number}: {line.strip()}")
return hits
def check_doc_paths(text: str, where: str) -> list[str]:
"""文档里出现的仓库路径必须真实存在(r1 P2-3:指针错会把人送到无关文件/章节)。"""
problems: list[str] = []
for token in sorted(set(PATH_RE.findall(text))):
if not (ROOT / token).exists():
problems.append(f"{where} 里写了不存在的路径:{token}")
return problems
def case_wiring(failures: list[str]) -> None:
"""真实文件的接线判据 + 文档路径存在性。"""
if not WORKFLOW.is_file():
failures.append(f"接线:{WORKFLOW} 不存在")
return
text = WORKFLOW.read_text(encoding="utf-8")
failures.extend(f"接线:{item}" for item in check_workflow(text))
hits = wired_elsewhere()
if hits:
failures.append("接线:有 workflow `uses:` 了 " + WORKFLOW.name + " ⇒ 播报变成了别人的必需检查:\n " + "\n ".join(hits))
for doc in (RUNBOOK, SIGNING_MD):
failures.extend(f"指针:{item}" for item in check_doc_paths(doc.read_text(encoding="utf-8"), doc.name))
def case_mutation(failures: list[str]) -> None:
"""变异重放:把接线改坏,**同一组判据原样套上去必须报红**(r1 收尾要求的第 ④ 项)。"""
real = WORKFLOW.read_text(encoding="utf-8")
if GATE not in real:
failures.append("变异:真实文件里就没有 ref 门,先修 P2-1 再谈变异")
return
vectors = [
(
"去掉 ref 门(种子对任意 ref 可见)",
real.replace(GATE, f"secrets.{KEY_ENV}"),
"ref 门",
),
(
"加回 pull_request 触发",
real.replace(" workflow_dispatch:\n", " pull_request:\n workflow_dispatch:\n", 1),
"pull_request",
),
(
"去掉非 main 的 WITHHELD 说明",
"\n".join(line for line in real.splitlines() if WITHHELD_ENV not in line) + "\n",
WITHHELD_ENV,
),
]
for label, mutated, expect in vectors:
if mutated == real:
failures.append(f"变异「{label}」没改到任何字节(变异无处下手,用例什么都没证明)")
continue
problems = check_workflow(mutated)
if not any(expect in item for item in problems):
failures.append(f"变异「{label}」没有被抓:期望命中「{expect}」,实际 {problems or '零问题'}")
# 文档指针的变异:把 runbook 里一个真实路径改成不存在的名字,同一判据必须报红。
runbook = RUNBOOK.read_text(encoding="utf-8")
broken = runbook.replace("packaging/docs/signing.md", "packaging/docs/signing-oldext.md", 1)
if broken == runbook:
failures.append("变异「文档路径」:runbook 里找不到可替换的路径,用例失效")
elif not check_doc_paths(broken, "mutated-runbook"):
failures.append("变异「文档路径」:改成不存在的路径后判据仍然是绿的 —— 路径存在性没被判")
def main() -> int:
failures: list[str] = []
case_mutation(failures)
case_wiring(failures)
for line in failures:
print(f"FAIL {line}", file=sys.stderr)
print("signing-status wiring selftest:", "ok" if not failures else f"{len(failures)} 处失败")
return 0 if not failures else 1
if __name__ == "__main__":
raise SystemExit(main())