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
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:
commit
a5d1dec495
1580 files changed
+320938
No files matched your search
@@ -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 证书,那是另一条线。
|
||||
@@ -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:]))
|
||||
@@ -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:]))
|
||||
@@ -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())
|
||||
@@ -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:]))
|
||||
@@ -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:]))
|
||||
@@ -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:]))
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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 会静默退化成「能起进程」
|
||||
而不是「能出图」(它本来也只证明「能出图」,不证明视觉正确)。
|
||||
@@ -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 触发流水线。
|
||||
@@ -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 自更新(换目录里的文件再重启)
|
||||
是另一件事,未规划。
|
||||
@@ -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` 两步只做结构断言)。
|
||||
@@ -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 冒烟都还没真跑过一次。)
|
||||
@@ -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**)。
|
||||
@@ -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`)。
|
||||
@@ -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 向量) |
|
||||
@@ -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` 的验收构建里另有一把**公开的测试钥**(种子写在测试夹具里):它只用于
|
||||
「本地目录源 + 真二进制」的端到端验收,**不在**正式信任表里,签不出任何正式版本能装的清单。
|
||||
@@ -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 文件**。
|
||||
@@ -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)))
|
||||
|
||||
|
||||
|
||||
@@ -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 才算绿)"
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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:]))
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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())
|
||||
@@ -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)"}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 不存在(拒绝出图就该一张都没有)"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user