Merge branch 'feat/m255-signready-signing-readiness-checker'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-11 20:22:55 +08:00
commit fa30dd9450
7 files changed
+1139

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
name: Signing status(播报,不是门禁)
# 第一句就是口径:**这是播报,不是门禁。它红了不代表不能合,它绿了也不代表签名就绪。**
# 就绪度的结论只在 job summary 的三行字里(`packaging/check_signing_ready.py --line` 写的),
# 不在 job 的颜色里。**本 workflow 不应被设为 required check**(GitHub 侧分支保护里不要勾它)。
#
# 为什么要有它(M255):M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION`
# 表在 `PINNED-KEYS` 标记之间是空的,而空表一律 `KeyringEmpty` 拒装 ⇒ 今天任何正式二进制都验不过任何
# 清单(fail-closed,用户端表现为「更新永远失败」)。工具链早就齐了,缺的是**可见性**:这三行读数让
# 「今天差什么」在每次运行里都摆在台面上,而不是等发版时才发现。
#
# 口径四条(由 `packaging/tests/signing_ready_wiring_selftest.py` 的接线判据机器校验,不是靠注释自律):
# ① 就绪度那一步抓 `rc=$?` 之后**恒 `exit 0`** —— 未就绪用 `::warning::` 播报,不用 error 级注解
# (error 会让人以为构建挂了,与播报定位直接冲突)。真正的红留在 `release-publish.yml` 的签名步:
# 那里今天必然红是**对的**,因为签不出来的产物本来就不能发。
# ② 文件名/名字都不带 guards、check 字样:它不是守卫,是一条状态播报。
# ③ 本文件**刻意不声明 `workflow_call:`**:没有这个触发器,别的 workflow 就没法 `uses:` 它 ——
# 「不能被接进 dev 线变成必需检查」是一条机制,不是一句嘱咐。接线自检另外扫全部 workflow,
# 任何 `uses: …/signing-status.yml` 出现即红(防的是有人先加 workflow_call 再接进来)。
# ④ **生产签名种子只在 `refs/heads/main` 上注入**,且**没有 `pull_request` 触发**(M255 r1 P2-1)。
# 这条是本文件唯一的**安全**口径,别为了「PR 上也能看读数」把它放开:
# - 仓库内分支的 PR / 任意分支的 dispatch **拿得到**仓库 Secret(只有 fork / Dependabot 拿不到);
# - 而这把种子能给**全量已安装客户端**签出被信任的清单(自更新链 = 用户端 RCE),
# 既有设计把它锁在 tag ref 后面(`release.yml` 的 `if: … github.ref_type == 'tag'`)。
# - 播报**不需要**在 PR 上跑:PR 改了 `check_signing_ready.py` 时,CI 会在那个 PR 自己的代码上执行它,
# 若同时注入种子,等于把种子交给任何能推分支的人。
# 门由两行 env 表达(同一个条件、同一个地方可审):钥给 main,非 main 则给 `ULL_SIGNING_KEY_WITHHELD`
# 一个原因串 —— 于是 ② 读成「未判定」而不是「没配」(把策略说成缺配是一次假读数)。
#
# 判据本体与 0/1/2 的语义:`packaging/check_signing_ready.py` 的文件头;
# 人类要做的那几步:`packaging/docs/signing-runbook.md`。
on:
push:
branches: [main]
# ⚠ 刻意**没有** `pull_request`(见口径④)。要读 PR 的签名就绪度,就跑 push 到分支或对着 main dispatch。
workflow_dispatch:
permissions:
contents: read
jobs:
# 尺子自测:它红 = **判据脚本坏了**(不是「签名没就绪」)。两者措辞在 summary 里分开写。
# 这一步照常会红,因为「尺子坏了」本来就该有人看见 —— 但它不阻断任何东西:本 workflow 独立、
# 非必需,也不在 `dev-guards.yml` 的 `uses:` 里。
selftest:
name: 判据自测(三行/退出码/变异重放/接线)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 判据本体(三行 / 0/1/2 / 两向退出码变异 / --key 用法错 / 指针章节)
run: python3 packaging/tests/signing_ready_selftest.py
# 接线单独一份(400 行红线):播报不是门禁、**生产种子只在 main 注入**、文档指针真的存在,
# 以及「去掉 ref 门 / 加回 pull_request / 改坏文档路径 ⇒ 必须红」三处变异重放。
- name: 接线(ref 门 / 不被别的 workflow 复用 / 无 error 级注解 / 文档路径存在性 + 三处变异)
run: python3 packaging/tests/signing_ready_wiring_selftest.py
- name: 真跑三种情形(今天的状态 / 假 PinnedKey 副本 / 用法错)
run: |
set -uo pipefail
work="$RUNNER_TEMP/signing-ready"
mkdir -p "$work"
# 结论只打印,不判定:这里要的是「三种情形各自的退出码与三行读数长什么样」,
# 判据在上一跑的 selftest 里。所有 `|| true` 只为让这一步本身不变红。
run_and_show() { # run_and_show <说明> <命令...>
note="$1"; shift
echo "── $note"
python3 packaging/check_signing_ready.py "$@" 2>&1 || true
echo
}
run_and_show "今天的状态(真实 trust.rs:表空 ⇒ 未就绪)"
# 副本夹一条假 PinnedKey(**不碰真实 trust.rs**):造出「就绪」的正路长什么样。
python3 - "$work" <<'PY'
import sys
from pathlib import Path
trust = Path("crates/core/src/update/trust.rs").read_text(encoding="utf-8")
begin = trust.index(">>> PINNED-KEYS >>>") + len(">>> PINNED-KEYS >>>")
end = trust.index("<<< PINNED-KEYS <<<")
fake = ' PinnedKey { key_id: "demo-not-a-real-key", public_key_hex: "' + "ab" * 32 + '" },\n'
Path(sys.argv[1], "trust-with-fake-key.rs").write_text(
trust[:begin] + "\n" + fake + trust[end:], encoding="utf-8"
)
PY
run_and_show "副本夹一条假 PinnedKey(②仍缺钥 ⇒ 仍未就绪,但 ① 会变就绪)" \
--trust "$work/trust-with-fake-key.rs"
run_and_show "用法错(不存在的 trust.rs ⇒ 2)" --trust "$work/nope/trust.rs" || true
# 播报:只把三行搬进 job summary,不看颜色。
status:
name: 签名就绪度(播报)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ② 要读「环境里有没有钥」,所以把 Secret 传进来 —— **只在 main ref 上**(口径④)。
# 门是下面这两行 env(同一个条件),不是 `if:`:这样非 main 的运行照样跑完三行读数,
# 只是 ② 读成「未判定」,而不是整个 job 被跳过后一片空白。
# ⚠ 这两行是安全边界,接线自检钉着它们(去掉 ref 门 / 加回 pull_request 都会当场红)。
- name: 三行就绪度(脚本给 0/1/2;本步恒 exit 0)
env:
ULL_MANIFEST_SIGNING_KEY: ${{ github.ref == 'refs/heads/main' && secrets.ULL_MANIFEST_SIGNING_KEY || '' }}
ULL_SIGNING_KEY_WITHHELD: ${{ github.ref == 'refs/heads/main' && '' || '非 main ref(按设计不注入 Secret;要读这一行请对 main 跑)' }}
run: |
set -uo pipefail
set +e
python3 packaging/check_signing_ready.py --line > "$RUNNER_TEMP/signing-ready.txt" 2>&1
rc=$?
set -e
cat "$RUNNER_TEMP/signing-ready.txt"
case "$rc" in
0) echo "::notice::签名就绪:信任表非空,且环境里的钥与它同源。这是一条播报,不是门禁。" ;;
1) echo "::warning::签名未就绪(或 ② 未判定):该做什么见 packaging/docs/signing-runbook.md §2 的下单清单。" ;;
*) echo "::warning::就绪度脚本用不了(rc=$rc,用法错):这是尺子的问题,不是签名状态 —— 看上面的 stderr。" ;;
esac
{
echo
echo "- 本 workflow 是**播报**,不是门禁:**不应被设为 required**,也不阻断任何其它 job。"
echo "- 颜色 ≠ 结论:绿色只说明脚本跑完了;结论只看上面三行(尤其 ①「信任表」)。"
echo "- **Secret 只在 \`refs/heads/main\` 上注入**(生产签名种子锁在 main / tag 后面);非 main ref 的 ② 显示「未判定」而不是「没配」。"
echo "- 真门禁在哪:\`release-publish.yml\` 的签名步(\`--check-pinned-keyring\` / \`--sign\` / \`--verify-pinned\`)。"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
+10
View File
@@ -81,6 +81,11 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
| `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_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/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/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/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 |
| `packaging/docs/linux-evidence.md` | Linux 槽位的证据账(M184):现有证据盘点、WSLg 真机实测输出、xvfb 复跑、CI 增量估算、未验清单 | ≤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/docs/gpu-evidence.md` | GPU 截图证据账(M249 + §8 CI 接线 M253:无 GPU 显式跳过、跳过 ≠ 通过、21 条接线变异):判据逐条「抓得住什么/抓不住什么」、本机实跑退出码、**能证**(字形栅格与边缘 AA / 面板 α 与玻璃 / 两后端一致性)与**不能证**(首帧与帧率 / 整窗 α 与圆角观感,走 DWM 任何截图都拍不到)、历史 `--diff` 基线永久不可复核 | ≤400 |
@@ -215,5 +220,10 @@ CUI(3),`grep -rn windows_subsystem crates/` 当时零命中。同一个修复
的**原始字节**,不是某个规范化后的 JSON)。信任锚是 `crates/core/src/update/trust.rs` 里的 的**原始字节**,不是某个规范化后的 JSON)。信任锚是 `crates/core/src/update/trust.rs` 里的
**编译期公钥数组**(keyring);源里没有 `.sig`、钥不在表里、签名对不上,一律 `signature_*` 分类拒装, **编译期公钥数组**(keyring);源里没有 `.sig`、钥不在表里、签名对不上,一律 `signature_*` 分类拒装,
**不可重试**。签名脚本 `packaging/sign_manifest.py`(零第三方依赖,`--self-test` 跑 RFC 8032 向量)。 **不可重试**。签名脚本 `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** - **不得混淆**:项目自有签名解决的是「更新器怎么判断这份清单是不是我们发的」,**不解决 SmartScreen**
——用户双击 exe 时看到的「未知发布者」要等 Authenticode 证书,那是另一条线。 ——用户双击 exe 时看到的「未知发布者」要等 Authenticode 证书,那是另一条线。
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""签名就绪度播报(M255):**只读**、零第三方依赖,三行说清「今天能不能签发一份客户端会认的清单」。
为什么要它:M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在
`PINNED-KEYS` 标记之间是**空的**,而空表一律 `KeyringEmpty` 拒装 —— 于是**今天任何正式二进制都验不过
任何清单**(fail-closed 是成立的,但在用户机器上表现为「更新永远失败」)。工具链早就齐了
(`sign_manifest.py` 的 `--generate-key` / `--check-pinned-keyring` / `--sign` / `--verify-pinned`),
缺的是**操作步骤与可见性**。本脚本只负责「现在差什么」,它不生成钥、不写任何文件、不改 `trust.rs`。
三行就绪度(各一条判据,互不替代):
| 行 | 判据 | 未就绪意味着 |
|---|---|---|
| ① | `PRODUCTION` 表非空:解析 `PINNED-KEYS` 标记之间的 `PinnedKey` 条目,0 条即未就绪 | 客户端一把公钥都没有,任何清单都验不过(今天就是这个状态) |
| ② | 环境里有签名钥:`ULL_MANIFEST_SIGNING_KEY`(或 `--key <文件>`)能解出 32 字节种子,**且**它的公钥在 ① 的表里 | 没钥签不了;钥与信任表不是同一把 ⇒ 发布时 `--check-pinned-keyring` 当场红 |
| ③ | 最近一次发布的 `manifest.json` / `.sig` 痕迹 | **不参与就绪判定**。取不到证据就写「未知」——「本地没留档」不等于「上次没签」 |
②里的对账(`公钥 ∈ 表`)是刻意加的:只说「有钥」会给出一个**看着就绪、发布却红**的假象,与本仓
「别再造假绿的尺子」的血泪账直接冲突。公钥是公开物,回显它是安全的;**种子(`seed_hex`)在本脚本里
一次都不打印**。
②还有第三种读数:**未判定**。CI 的播报 workflow 刻意只在 `main` ref 上注入 Secret(生产签名种子等于
「给全量已装客户端签可信清单」的能力,既有设计把它锁在 tag ref 后面),非 main ref 的运行里这一行会写
「未判定 —— 非 main ref(按设计不注入)」,**不写「没配」**:把「策略不注入」说成「没配」就是一次假读数。
退出码是对外接口(改动即行为变化,`packaging/tests/signing_ready_selftest.py` 钉着它):
`0` 就绪 / `1` 未就绪(含 ② 未判定) / `2` 用法错(trust.rs 或 `--key` 读不到、标记丢了 ——
那时脚本形不成判断,不是「未就绪」)。
**它不是门禁**:CI 里的 `.github/workflows/signing-status.yml` 只把这三行搬进 job summary,
不因「未就绪」变红。理由见 `packaging/docs/signing-runbook.md` §6。
python3 packaging/check_signing_ready.py
python3 packaging/check_signing_ready.py --trust <副本 trust.rs> --key <钥文件> --dist dist
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SIGN = ROOT / "packaging/sign_manifest.py"
RUNBOOK = "packaging/docs/signing-runbook.md"
#: 未就绪时的固定措辞:判据与「人类一眼看懂后果」绑在一起(用例按字面断言这一句)。
FAIL_CLOSED = "这会让已安装用户永远验不过(fail-closed)"
#: 「这一趟按设计没有钥」的原因(人话)。CI 的 `signing-status.yml` 在非 main ref 上把它设成一句话,
#: 于是 ② 读成「未判定」而不是「没配」—— 把**策略不注入**说成**没配置**是一次假读数(M255 r1 P2-1)。
WITHHELD_ENV = "ULL_SIGNING_KEY_WITHHELD"
sys.path.insert(0, str(ROOT / "packaging"))
# 标记 / 环境变量名 / 信任表路径一律**从 sign_manifest.py 取**,不在这里写第二份字面量:
# 两份标记各自漂开时,`--check-pinned-keyring` 与就绪度会给出互相矛盾的读数。
from sign_manifest import KEY_ENV, KID_RE, PIN_BEGIN, PIN_END, TRUST_RS, load_key # noqa: E402
from ed25519_ref import public_key # noqa: E402
Line = tuple[bool, str] # (就绪?, 该行文本)
def table_state(path: Path) -> tuple[bool, str, list[tuple[str, str]]]:
"""① 判据:`PINNED-KEYS` 标记之间有至少一条 `PinnedKey`。
标记缺失 / 文件读不到 ⇒ `RuntimeError`(调用方翻成退出码 2:脚本无法形成判断)。
"""
text = path.read_text(encoding="utf-8")
begin, end = text.find(PIN_BEGIN), text.find(PIN_END)
if begin < 0 or end < begin:
raise RuntimeError(
f"{path} 里找不到 {PIN_BEGIN} / {PIN_END} 标记(标记本身不许动 —— sign_manifest.py 也按它解析)"
)
inside = text[begin:end]
keys = KID_RE.findall(inside)
line_no = text[:begin].count("\n") + 1
if keys:
names = "、".join(f"{kid}({pk[:16]}…)" for kid, pk in keys)
return True, f"就绪 —— 标记之间 {len(keys)} 条:{names}({path}:{line_no} 起)", keys
# 空表就是今天的状态。顺手查一个真实易错点:有人把条目贴到标记**外面**(`];` 之后)。
# ⚠ 本仓另有一把 `TEST_ONLY` 测试钥**本来就在标记之外**(那是它的正确位置),所以这里只**点名**,
# 不替人下结论 —— 上一版写成「贴错位置,等于没钉」,在真实 trust.rs 上是误报(实测抓出来的)。
strays = [kid for kid, _ in KID_RE.findall(text[:begin]) + KID_RE.findall(text[end:])]
hint = ""
if strays:
hint = (
f";文件里另有 {len(strays)} 条在标记之外(key_id:{'、'.join(strays[:3])})"
"—— `TEST_ONLY` 那条本来就在外面,属正常;**生产钥**贴在外面等于没钉"
)
return False, f"未就绪 —— 标记之间 0 条 PinnedKey{hint}({path}:{line_no} 起)", []
def read_key_text(key_file: str | None) -> str:
"""② 的输入文本:`--key <文件>` 或环境变量 `ULL_MANIFEST_SIGNING_KEY`。
读不了(不存在 / 是目录 / 无权限 / 非 UTF-8)⇒ `OSError` / `UnicodeDecodeError`,由 `main` 翻成
**退出码 2**(用法错)。⚠ 这里必须抛而不是吞:吞掉就会变成 rc=1,与「未就绪」同码,
而事实是「你给的路径就是错的」——两者混了,退出码就不再可信(M255 r1 P2-2)。
"""
if key_file:
return Path(key_file).read_text(encoding="utf-8")
return os.environ.get(KEY_ENV, "")
def key_state(raw: str, source: str, pinned: list[tuple[str, str]]) -> tuple[bool, str, str | None]:
"""② 判据:环境里有钥、解析得出、且它的公钥在 ① 的表里。
返回 `(就绪?, 该行文本, 可直接贴进 trust.rs 的那一行 PinnedKey)`。
**任何分支都不打印种子**;回显的只有 `key_id` 与公钥十六进制。
"""
if not raw.strip():
reason = os.environ.get(WITHHELD_ENV, "").strip()
if reason:
# 「策略上不注入」≠「没配置」。这一行必须能分辨,否则非 main ref 的运行会报假的「没配」。
return False, f"未判定(未注入)—— 这一趟的钥按设计没有注入:{reason};这不是「没配」", None
return False, f"未就绪 —— {source} 未设置/为空(没有私钥就签不出任何清单)", None
try:
key_id, seed = load_key(raw)
except SystemExit as error: # load_key 用 SystemExit 报错(它会 sys.exit);这里降级成一行读数
return False, f"未就绪 —— {source} 读不出钥:{error}", None
pk_hex = public_key(seed).hex()
paste = f'PinnedKey {{ key_id: "{key_id}", public_key_hex: "{pk_hex}" }},'
for pinned_id, pinned_hex in pinned:
if pinned_hex.lower() == pk_hex:
note = "" if pinned_id == key_id else f"(表里叫 {pinned_id},钥文件叫 {key_id};判据是公钥字节,能过)"
return True, f"就绪 —— {source} 里有钥 {key_id},公钥 {pk_hex} 已在信任表里{note}", paste
if pinned:
return False, f"未就绪 —— {source} 里有钥 {key_id}(公钥 {pk_hex}),但**不在**信任表里,发布时会被拦", paste
return False, f"未就绪 —— {source} 里有钥 {key_id}(公钥 {pk_hex}),但信任表是空的,没处钉", paste
def release_state(dist: Path | None, assets: Path | None) -> tuple[bool | None, str]:
"""③ 判据:最近一次发布有没有 `manifest.json` / `.sig` 痕迹。**只取证,不参与就绪判定。**
`None` = 未知(既没给 `--dist` 里的留档,也没给 `--release-assets`)。「本地没留档」不是
「上次没签」的证据,所以这一档只写「未知」,不写「无」。
"""
if dist is not None and dist.is_dir():
manifest = dist / "manifest.json"
signature = dist / f"{manifest.name}.sig"
if manifest.is_file() and signature.is_file():
return True, f"有 —— 本地留档 {manifest} + {signature}"
if manifest.is_file():
return False, f"无 —— 本地 {dist} 里有 manifest.json 但**没有** .sig(那份清单客户端会拒收)"
if assets is not None and assets.is_file():
text = assets.read_text(encoding="utf-8", errors="replace")
# 先剥掉 `.sig` 再判清单:`manifest.json.sig` 里含 `manifest.json`,只做子串匹配时
# 「资产里只有签名」会被读成「清单和签名都在」(r1 复审实测抓到的假读数)。
has_sig = "manifest.json.sig" in text
has_manifest = "manifest.json" in text.replace("manifest.json.sig", "")
if has_manifest and has_sig:
return True, f"有 —— {assets} 的资产清单里同时有 manifest.json 与 manifest.json.sig"
if has_manifest:
return False, f"无 —— {assets} 的资产清单里有 manifest.json,但没有 manifest.json.sig(没签名)"
return False, f"无 —— {assets} 的资产清单里既没有 manifest.json 也没有 .sig(那次发布没走上签名这条链)"
looked = [str(p) for p in (dist, assets) if p is not None]
where = f"(看过:{'、'.join(looked)})" if looked else "(没给 --dist / --release-assets,也没本地 dist/ 留档)"
return None, f"未知 —— 取不到任何发布产物证据{where}"
def fix_hint(table_ok: bool, key_ok: bool, paste: str | None) -> str:
"""未就绪时的**一行**修复命令(不带任何密钥值;钥的事只指向 Secret 名与手册)。
指针必须落在手册**真实**的章节上:`packaging/tests/signing_ready_wiring_selftest.py` 会拿这里的
`§N` 去对 `signing-runbook.md` 的标题集合,漂了当场红(M255 r1 P2-3:`signing-ready.yml` 那个
文件名根本不存在,而指针正是本文件的交付物)。
"""
if not table_ok and paste:
return f"修复:把这一行贴进 {TRUST_RS.name} 的 PINNED-KEYS 标记之间 —— {paste}(手册 {RUNBOOK} §2 步③)"
if not table_ok:
return (
"修复:python3 packaging/sign_manifest.py --generate-key --key-id <key-id> "
f"→ 私钥贴进 Secret {KEY_ENV},打印的 PinnedKey 那行贴进 trust.rs 标记之间(手册 {RUNBOOK} §2 步①–③)"
)
if not key_ok and paste:
return f"修复:把这一行贴进 {TRUST_RS.name} 的 PINNED-KEYS 标记之间 —— {paste}(手册 {RUNBOOK} §2 步③)"
return f"修复:把密钥贴进仓库 Secret {KEY_ENV}(内容 = --generate-key 打印的那行 JSON;手册 {RUNBOOK} §2 步②)"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="签名就绪度播报:三行读数 + 0/1/2 退出码(M255)")
parser.add_argument("--trust", type=Path, default=TRUST_RS, help=f"trust.rs 路径(默认 {TRUST_RS})")
parser.add_argument("--key", metavar="FILE", help=f"钥文件(不给则读环境变量 {KEY_ENV})")
parser.add_argument("--dist", type=Path, default=Path("dist"), help="本地留档目录(默认 dist/;不存在则不看)")
parser.add_argument("--release-assets", type=Path, metavar="FILE", help="某次 Release 的资产清单文本(如 gh release view 的输出)")
parser.add_argument("--line", action="store_true", help="CI 用:额外把三行写进 $GITHUB_STEP_SUMMARY")
args = parser.parse_args(argv)
try:
table_ok, table_line, pinned = table_state(args.trust)
except (OSError, UnicodeDecodeError, RuntimeError) as error:
print(f"error: 读不了信任表:{error}(脚本无法形成判断,按用法错退出)", file=sys.stderr)
return 2
try:
key_text = read_key_text(args.key)
except (OSError, UnicodeDecodeError) as error:
# 与 `--trust` 同款:输入读不到 = 用法错(2)。**不能**退化成 1 —— 1 是「签名未就绪」的读数,
# 混了就会把「我路径写错了」读成「签名没配好」(M255 r1 P2-2)。
print(f"error: 读不了钥文件 {args.key}:{error}(这是用法错,不是「未就绪」)", file=sys.stderr)
return 2
source = f"--key {args.key}" if args.key else f"环境变量 {KEY_ENV}"
key_ok, key_line, paste = key_state(key_text, source, pinned)
release_ok, release_line = release_state(args.dist, args.release_assets)
withheld = not key_ok and not key_text.strip() and bool(os.environ.get(WITHHELD_ENV, "").strip())
lines: list[Line] = [
("① 信任表 PRODUCTION", table_ok, table_line),
("② 环境签名钥", key_ok, key_line),
("③ 最近一次发布痕迹", release_ok is not False, release_line),
]
for label, _ok, text in lines:
print(f"{label}:{text}")
ready = table_ok and key_ok
if ready:
print(f"结论:就绪({args.trust} 里钉了钥,环境里的钥与它同源)—— 可以按 {RUNBOOK} §2 步⑤ 发一次带 .sig 的 Release,再按 §5 回验")
elif withheld:
# 「未判定」不许伪装成「未就绪」:这一趟根本没拿到钥,不是仓库缺钥。
tail = f";① 仍是空的 ⇒ {FAIL_CLOSED}" if not table_ok else ""
print(f"结论:未判定(② 这一趟没注入钥,见上面那行的原因){tail}")
else:
blockers = [label for label, ok, _ in lines[:2] if not ok]
print(f"结论:未就绪(差 {'、'.join(blockers)})—— {FAIL_CLOSED}")
print(fix_hint(table_ok, key_ok, paste))
if args.line:
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
verdict = "就绪" if ready else ("未判定" if withheld else "未就绪")
body = ["## 签名就绪度(M255,播报不是门禁)", ""]
body += [f"- **{label}**:{text}" for label, _ok, text in lines]
body += [
"",
f"- 结论:**{verdict}**",
f"- 本 job 的绿色**不代表签名就绪**:判据只看上面三行字,退出码恒为 0(见 `{RUNBOOK}` §6)。",
]
Path(summary).write_text("\n".join(body) + "\n", encoding="utf-8")
return 0 if ready else 1
if __name__ == "__main__":
raise SystemExit(main())
+190
View File
@@ -0,0 +1,190 @@
# 签名就绪度操作手册(M255)
本文件是**人类照做**的手册:从「信任表是空的」走到「发出去的 Release 用客户端判据能验过」。
背景/威胁模型/轮换纪律在 [`signing.md`](signing.md);本文件只讲**步骤、命令、坑**。
> ⚠ 本文件**不含**任何真实私钥、种子或公钥。所有示例都是 `<尖括号占位符>`。
> `--generate-key` 的输出是**敏感物**:只在你要贴进 Secret 的那一次运行里存在,别存文件、别贴 issue。
## 0. 现状读一遍(2026-11 的 M254 审计结论)
`crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是**空的**。空表 =
`KeyringEmpty` 一律拒装 ⇒ **今天任何正式二进制都验不过任何清单**(fail-closed 成立,但用户端表现为
「更新永远失败」)。已存在的部分(都能用):
| 环节 | 在哪 | 今天的状态 |
|---|---|---|
| 生成钥 | `sign_manifest.py --generate-key` | 有;打印 Secret JSON + `PinnedKey` 那一行 |
| 钥 ↔ 信任表对账 | `sign_manifest.py --check-pinned-keyring` | 有;**今天必然红**(表空 ⇒ 「信任表是空的」) |
| 签名 | `sign_manifest.py --sign dist/manifest.json` → `.sig` | 有;**今天必然红**(`ULL_MANIFEST_SIGNING_KEY` 没有,前面先红) |
| 回验 | `sign_manifest.py --verify-pinned dist/manifest.json` | 有;**今天必然红**(表空 ⇒ 「无从回验」) |
| 发布接线 | `release-publish.yml` 的「签更新清单」步 | 有;上面四条任一非零 ⇒ 不出 Release(**这是对的**) |
| 资产上传 | `release-publish.yml` 的 `assets=(… manifest.json manifest.json.sig …)` | 有 |
| **操作步骤与可见性** | 本文件 + `packaging/check_signing_ready.py` + `signing-status.yml` | **本次补上的就是这两样** |
一句话:**工具链不缺,缺的是有人把钥钉进表里。** 那件事只有人类能做(塔不会自己生成生产私钥)。
## 1. 先看读数(不用碰任何钥)
```bash
python3 packaging/check_signing_ready.py # 三行读数 + 退出码
python3 packaging/check_signing_ready.py --dist dist # 顺手看一眼本地留档
```
三行分别是 ① `PRODUCTION` 表空不空 ② 环境里有没有签名钥(并顺手对账「这把钥在不在表里」)
③ 最近一次发布的 `.sig`/清单痕迹(取不到就写「未知」——本地没留档 ≠ 上次没签)。
退出码:`0` 就绪 / `1` 未就绪 / `2` 用法错(参数写错、`trust.rs` 读不到、标记丢了)。
**未就绪时会打印一行修复命令**,直接照抄即可。CI 里同一条读数在 `Signing status(播报)` workflow 的
job summary 里(见 §6 —— 它是播报,不是门禁)。
## 2. 人类要亲手做的 5 步(下单清单)
下面每一行都只有命令与占位符,**没有任何密钥值**。做完一步就把该步的验证跑一遍。
> 🔴 **顺序硬要求(M255 r1 P2-1):先合并「播报 workflow 的 ref 门」那笔改动,再录 Secret。**
> 播报 workflow(`.github/workflows/signing-status.yml`)会把 `ULL_MANIFEST_SIGNING_KEY` 注入到
> 就绪度那一步的环境里;那个注入**只在 `refs/heads/main` 上发生**,而且该 workflow **没有**
> `pull_request` 触发。这两条是安全边界:仓库内分支的 PR 与任意分支的 `workflow_dispatch` 都拿得到
> 仓库 Secret,而 CI 会在**那个 PR 自己的代码**上执行 checker —— 种子一旦进过那种运行,等于交给
> 任何能推分支的人(这把种子能给**全量已安装客户端**签可信清单,即用户端 RCE)。
> 所以:**ref 门那笔没合进去之前不要录 Secret**;录之前先确认线上版本里有
> `github.ref == 'refs/heads/main'` 这个条件式(§6 也写了这条)。
```bash
# ① 生成钥(⚠ 输出是敏感物;不要跑在 CI 里,不要重定向到仓库里的文件)
python3 packaging/sign_manifest.py --generate-key --key-id <key-id,例 ull-release-2026a>
# ② 把打印出来的那一行 JSON({"key_id": …, "seed_hex": …})贴进仓库 Secret(⚠ 先读上面的顺序硬要求):
# Settings → Secrets and variables → Actions → New repository secret
# 名字必须**逐字**是:ULL_MANIFEST_SIGNING_KEY
# (等价 CLI:gh secret set ULL_MANIFEST_SIGNING_KEY,然后按提示粘贴,别用 -b 把值留在 shell 历史里)
# ③ 把打印出来的那一行 PinnedKey { … } 贴进 trust.rs 的 PINNED-KEYS 标记之间(标记本身别动)
grep -n 'PINNED-KEYS' crates/core/src/update/trust.rs
# 贴完提交这次改动 —— 公钥进 git 是对的:它是信任锚,本来就该公开可审计
# ④ 对账(需要一个有钥的环境:本机就先 export,别写进任何文件)
python3 packaging/sign_manifest.py --check-pinned-keyring # 期望:ok: 签名钥 <key-id> 已被钉住
# ⑤ 发一次带 .sig 的 Release,然后用**客户端的判据**回验(这一步不需要私钥)
python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # 期望:ok: 用钉住的公钥能验过
```
`--verify-pinned` 才是证据:`--sign` 说「签好了」不算 —— 客户端拿的是 `trust.rs` 里那把公钥。
发布后建议再用**下载下来的资产**验一次(§5),因为「CI 上能验过」不等于「上传的那份就是被签的那份」。
### 每一步的判据(怎么知道这步真的成了)
| 步 | 成功长什么样 | 失败长什么样 |
|---|---|---|
| ① | 打印 ① 私钥 JSON、② 一行 `PinnedKey`、③ 公钥十六进制;脚本自带「自己签自己验」自检 | `error: 自己签自己验不过` ⇒ 不要用这把钥,环境有问题 |
| ② | `gh secret list` 里能看到 `ULL_MANIFEST_SIGNING_KEY` | 列表里没有 / 名字拼错(大小写、下划线) |
| ③ | `check_signing_ready.py` 的 ① 从「未就绪」翻成「就绪」 | ① 仍说 0 条 ⇒ 贴到了标记**外面**(读数里会点名「另有 N 条在标记之外(key_id:…)」) |
| ④ | `ok: 签名钥 … 已被钉住` | `签名钥(…)不在信任表里` ⇒ ②③ 不是同一把钥 |
| ⑤ | `ok: manifest.json.sig 用钉住的公钥 … 能验过(客户端会接受)` | `拿 trust.rs 里钉住的公钥回验不过 —— 这份 Release 的用户会全部拒装。别发版` |
## 3. 一次发布里签名是怎么被执行的(不用额外操作)
`release-publish.yml` 的「签更新清单」步按顺序跑四条,任一非零 ⇒ 本步红 ⇒ 不出 Release:
```bash
sign_manifest.py --self-test # RFC 8032 向量:脚本自己没坏
sign_manifest.py --check-pinned-keyring # Secret 里的钥必须已在 trust.rs 钉住
sign_manifest.py --sign dist/manifest.json # 产出 dist/manifest.json.sig
sign_manifest.py --verify-pinned dist/manifest.json # 用钉住的公钥回验产出
```
这一步已经把「未配置 Secret」「钥没钉」「钥不匹配」都变成**发版前的红**,所以「发了但没签名」这条
不会发生 —— 它只会在发布 job 上红,不会静默发出去。
## 4. 已知坑(按踩到的概率排序)
1. **Secret 名字拼错**(`ULL_MANIFEST_...` vs `ULL_MANIFEST_KEY`):可复用工作流只拿得到自己**声明过**的
secret,名字不对就是空串 ⇒ 签名步红,而错误文案会反过来指控「Secret 没配」。`release-publish.yml`
已经声明了这一个(`required: false`,缺省走 fail-closed),照抄名字即可。
2. **只配 Secret 没钉表**:`--check-pinned-keyring` 报「信任表是空的(fail-closed)」,并**直接打印**
该贴的那一行 `PinnedKey`(只含公钥,安全)。照贴即可。
3. **只钉表没配 Secret**:`--check-pinned-keyring` 报「没有签名密钥」。这不影响已有用户(表非空 ⇒
他们能验签),但发不了新版。
4. **Secret 与 trust.rs 是两把不同的钥**:最常见于「换过钥但只更新了一边」。判据是**公钥字节**,
`key_id` 只是标签;`--check-pinned-keyring` 会把两边的公钥都打出来对账。
5. **把条目贴到标记外面**(`];` 之后、或贴着别的 `const`):等于没钉。`check_signing_ready.py` 会**点名**
标记之外的 `key_id`;注意本仓 `TEST_ONLY` 那条**本来就在标记之外**(那是它的正确位置),所以读数是
「另有 N 条在标记之外(key_id:…)」而不是「贴错了」—— 对着 `key_id` 自己认。
6. **`set -x` / `echo $ULL_MANIFEST_SIGNING_KEY`**:一次就够把私钥泄进日志。工作流里那一步显式 `set +x`
且只回显**公钥**;本机调试同理,别开 trace。
7. **`--generate-key` 的输出落进文件/issue/聊天**:当成泄露处理,走 `signing.md` §5 轮换(旧钥签新钥)。
8. **以为「非 main 分支的读数」就是仓库状态**:播报 workflow **只在 `refs/heads/main` 上注入** Secret
(也没有 `pull_request` 触发 —— 见 §2 的顺序硬要求与 §6)。非 main ref 的运行里 ② 显示
「**未判定** —— 非 main ref(按设计不注入 Secret)」,这是**策略**,不是「没配」。以 `main` 上的读数为准。
9. **`.sig` 没上传到 Release**(手改过 `assets=(…)` 那一行):客户端取 `<产物根>/manifest.json.sig`
会 404 ⇒ 所有人验签失败。上传后请在资产列表里**亲眼看到** `manifest.json` 与 `manifest.json.sig`
两个名字(§5 的 `gh release view` 那条命令就是干这个的)。
10. **删掉旧钥条目太早**:用户机器上装着旧二进制,它只认旧钥;删条目的那个版本必须**只**由新钥签,
并且至少一个发布周期的双签名之后(`signing.md` §5)。
11. **「昨天验过」不等于「今天验得过」**:`PRODUCTION` 是**编译期**常量,改 `trust.rs` 只影响**下一次构建**。
已在用户盘上的旧二进制永远用旧表 —— 这是设计,不是 bug。
12. **给播报 workflow 加回 `pull_request` 触发、或去掉注入行的 ref 门**:等于把生产签名种子交给任意推
分支的人的代码(自更新链 = 用户端 RCE)。这两处各有接线用例钉着(去掉任一处都会红),
别为了「PR 上也能看读数」去动它 —— 看读数请对 `main` 跑,或读 §1 在本地跑。
## 5. 发布后回验(发布侧,不需要私钥)
```bash
# 资产里必须两个名字都在(漏一个等于全员验签失败)
gh release view <tag> --json assets --jq '.assets[].name' | grep -E 'manifest\.json(\.sig)?$'
# 拿下载回来的真实资产复现客户端判据:manifest.json 与 .sig 必须成对、且是同一份字节
mkdir -p /tmp/verify && gh release download <tag> --pattern 'manifest.json*' --dir /tmp/verify
python3 packaging/sign_manifest.py --verify-pinned /tmp/verify/manifest.json
```
退出码 0 = 「客户端会接受这份清单」。非零时**不要**手动 `edit` Release 绕过 —— 先看脚本 stderr
(它会说清楚是表空、无 `.sig`、还是签名对不上)。
## 6. CI 里那条读数:`Signing status(播报)`
`.github/workflows/signing-status.yml` 在 `push: branches: [main]` 与**手动 dispatch** 上跑,把 §1 的三行
搬进 job summary。**它不是门禁**:
* 它**不应被设为 required check**(分支保护里不要勾它),也不阻断任何其它 job;
* 它就绪度那一步**恒 `exit 0`**:未就绪用 `::warning::` 播报,不用 error 级注解(那会让人以为构建挂了);
* **生产签名种子只在 `refs/heads/main` 上注入**(`github.ref == 'refs/heads/main' && secrets.… || ''`),
而且**没有 `pull_request` 触发**。理由是安全:仓库内分支的 PR / 任意分支的 dispatch 都拿得到仓库 Secret,
而 CI 跑的是那个 ref 自己的代码 —— 种子进过那种运行就等于交给任何能推分支的人。
非 main ref 的运行里 ② 显示「**未判定** —— 非 main ref(按设计不注入)」,**不写「没配」**;
* 它**刻意不声明 `workflow_call:`**,所以别的 workflow 没法 `uses:` 它;接线自检还会扫全部 workflow,
任何 `uses: …/signing-status.yml` 出现即红(防的是「顺手接进 dev 线把播报变成门禁」);
* 它**绿**只说明脚本跑完了;结论只看 summary 里的三行字。
* 同一 workflow 里另有两步跑自测(`packaging/tests/signing_ready_selftest.py` +
`packaging/tests/signing_ready_wiring_selftest.py`):那两步**会红**,红 = **尺子坏了**(三行读数/退出码/
变异重放/接线与 ref 门之一没过),与「签名未就绪」是两件不同的事,措辞在 summary 里分开写。
为什么不做成门禁:今天已经有四次「守卫假绿」的账(见 `packaging/README.md` §12 里那几条先例)。
把一条**按定义今天必然失败**的检查做成 required,只会有两种结局 —— 要么所有人学会忽略红叉,
要么有人把它改成永远绿。真正的红留在 `release-publish.yml` 的签名步:那里今天红是**对的**,
因为签不出来的产物本来就不能发。
## 7. 本手册**没**覆盖 / 没验的部分(明账)
* **Authenticode / SmartScreen**:项目自有签名解决的是「这条自更新链是不是我们发的」,用户双击 exe 看到
的「未知发布者」与此无关(`signing.md` §0)。
* **托管签名**(如 SignPath):只是后续可替换路径,本手册与判据都不依赖它。
* **本文档里的命令没有被真跑过一遍**:`--generate-key` 会产出生产私钥,塔不生成、也不跑它;
②③ 两步要动 GitHub Secret 与 Rust 常量,同理。所以本手册是「按现有脚本的接口写的步骤」,
首次执行时**以脚本的实际输出为准**,跑完请回来修正本文档。
* **「用户机器上真的更新成功」**没有在本条里验:需要一次真 Release + 一台装着旧版本的机器。
* **ref 门只在本地验证过**(抽 `run:` 块用 Git Bash 跑 + 接线用例的文本变异),没有在真 Actions 上看过
`secrets` 的求值结果 —— 第一次真跑请确认非 main ref 的 ② 显示「未判定」。
## 8. 相关文件
| 文件 | 作用 |
|---|---|
| `packaging/check_signing_ready.py` | 三行就绪度 + `0/1/2` 退出码(只读、零第三方依赖) |
| `packaging/tests/signing_ready_selftest.py` | 判据自测:三行/退出码/两向变异重放/`--key` 用法错/指针章节 |
| `packaging/tests/signing_ready_wiring_selftest.py` | 接线自测:不是门禁 / 不被 `uses:` / **ref 门** / 文档路径存在性 + 三处变异 |
| `.github/workflows/signing-status.yml` | 把三行搬进 job summary 的**播报** workflow(不是门禁;种子只在 main 注入) |
| `packaging/docs/signing.md` | 为什么签名、威胁模型、轮换纪律、四条发布侧命令的红法对照表 |
| `packaging/sign_manifest.py` | 生成钥 / 签名 / 对账 / 回验的唯一实现(`--self-test` 跑 RFC 8032 向量) |
+6
View File
@@ -3,6 +3,12 @@
本文件是**发布侧手册**:私钥从哪来、怎么用、怎么轮换、哪一步失败必须让发布变红。 本文件是**发布侧手册**:私钥从哪来、怎么用、怎么轮换、哪一步失败必须让发布变红。
用户面的说明(README 里「为什么第一个包要手动下」那类话)不在本文件范围。 用户面的说明(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. 先说清这件事**不**解决什么 ## 0. 先说清这件事**不**解决什么
| 有 / 没有 | 状态 | | 有 / 没有 | 状态 |
+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""`packaging/check_signing_ready.py` 的判据自测(M255):让「三行读数 + 0/1/2 退出码」变成可执行的事实。
为什么它必须存在:这个脚本的全部价值是「说清今天差什么」。它一旦退化成「永远 exit 0」,
它就从**播报**变成了本仓栽过四次的**假绿尺子** —— 而且没人会当场发现(输出看起来一模一样)。
所以这里的第一类用例判的是退出码语义,第二类用例判的是**把退出码改绿之后自测会红**(变异重放)。
| 用例 | 抓什么 |
|---|---|
| `empty_table` | ① 表空 ⇒ `exit 1`、三行都打印、有 fail-closed 句 + 修复行 |
| `pinned_and_key` | ② 副本夹假 `PinnedKey` ⇒ `exit 0`(**不碰真实 `trust.rs`**),且不泄种子 |
| `mutation` A/B | 退出码永远 0 ⇒ 判据①必须红;永远 1 ⇒ 判据②必须红(两向都要) |
| `key_mismatch` / `no_key` / `release_*` | ② 对账、③ 有/无/未知(含 `.sig` 子串假读数)、③ 不参与退出码 |
| `key_unreadable` | `--key` 不存在 / 是目录 ⇒ **2**(不是 1,也不是 traceback —— r1 P2-2) |
| `withheld` | 非 main ref ⇒ ② 写「未判定」+ 原因,**不写「没配」**(r1 P2-1 第三条) |
| `section_refs` | 输出里每个 `§N` 都是 runbook 真实章节号(r1 P2-3) |
| `stray_hint` / `no_seed_leak` / `usage` / `summary` | ① 标记外条目只点名不误报、种子不出现、用法错 = 2、`--line` 写 summary |
接线(不是门禁 / 不被 `uses:` / **ref 门** / 文档路径存在性)在
**`packaging/tests/signing_ready_wiring_selftest.py`** —— 拆成两份只因为全仓 400 行红线。
两份都不覆盖「整个 workflow 被删掉」(守门人住在被守对象旁边,与 `gpu_shot_ci_selftest.py` 同一条边界)。
`DUMMY_SEED` 是**由一句固定文本哈希出来的**假种子(同 `trust.rs` 里 `TEST_ONLY` 那把公开测试钥同一路数),
只用来造读数,签不出任何正式清单;文档与本文件都不含任何真实私钥。
python3 packaging/tests/signing_ready_selftest.py
"""
from __future__ import annotations
import hashlib
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
SCRIPT = ROOT / "packaging/check_signing_ready.py"
RUNBOOK = ROOT / "packaging/docs/signing-runbook.md"
KEY_ENV = "ULL_MANIFEST_SIGNING_KEY"
WITHHELD_ENV = "ULL_SIGNING_KEY_WITHHELD"
FAIL_CLOSED = "这会让已安装用户永远验不过(fail-closed)"
LABELS = ("① 信任表 PRODUCTION", "② 环境签名钥", "③ 最近一次发布痕迹")
sys.path.insert(0, str(ROOT / "packaging"))
from ed25519_ref import public_key # noqa: E402
DUMMY_SEED = hashlib.sha256(b"ull-m255-signing-ready-selftest-not-a-real-key").hexdigest()
DUMMY_PUB = public_key(bytes.fromhex(DUMMY_SEED)).hex()
OTHER_SEED = hashlib.sha256(b"ull-m255-selftest-the-other-dummy-key").hexdigest()
TRUST_TEMPLATE = """\
pub(crate) const PRODUCTION: &[PinnedKey] = &[
// >>> PINNED-KEYS >>>
{body} // <<< PINNED-KEYS <<<
];
"""
def secret_json(seed: str = DUMMY_SEED, key_id: str = "selftest-key") -> str:
return '{"key_id": "%s", "seed_hex": "%s"}' % (key_id, seed)
def write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8", newline="\n")
return path
def run(
args: list[str],
env_key: str | None,
summary: Path | None = None,
script: Path = SCRIPT,
withheld: str | None = None,
) -> tuple[int, str]:
"""跑一次脚本,返回 `(退出码, stdout+stderr)`。
`env_key` = 塞进 `KEY_ENV` 的内容(`None` = 不设);`withheld` = 塞进 `WITHHELD_ENV` 的内容。
两个 key 都先**从父环境剔除**再按参数注入:否则本机残留的变量会让用例假绿。
"""
env = {k: v for k, v in os.environ.items() if k not in (KEY_ENV, WITHHELD_ENV)}
env.pop("GITHUB_STEP_SUMMARY", None)
# 子进程与父进程的编码显式对齐成 UTF-8:本机 Windows 默认 gbk,中文读数会 decode 失败,
# 而 CI(ubuntu)本来就是 UTF-8。这条不是洁癖 —— 编码不一致会让整份自测在 Windows 上假红/假绿。
env["PYTHONIOENCODING"] = "utf-8"
if env_key is not None:
env[KEY_ENV] = env_key
if withheld is not None:
env[WITHHELD_ENV] = withheld
if summary is not None:
env["GITHUB_STEP_SUMMARY"] = str(summary)
done = subprocess.run(
[sys.executable, str(script), *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=env,
cwd=str(ROOT),
check=False,
)
return done.returncode, done.stdout + done.stderr
def case_empty_table(work: Path, failures: list[str], script: Path = SCRIPT) -> None:
"""① 今天的状态:空表 ⇒ exit 1,三行都要打印,且必须点明 fail-closed 后果与修复命令。
`script` 可换:变异用例把**同一组断言**原样套在「退出码永远 0」的副本上,断言集合必须报红。
"""
trust = write(work / "empty/trust.rs", TRUST_TEMPLATE.format(body=""))
rc, out = run(["--trust", str(trust)], None, script=script)
if rc != 1:
failures.append(f"判据①:空表应 exit 1,实际 {rc}\n{out}")
missing = [label for label in LABELS if label not in out]
if missing:
failures.append(f"判据①:空表时没打印这三行中的 {missing}\n{out}")
if FAIL_CLOSED not in out:
failures.append(f"判据①:未就绪时必须写明「{FAIL_CLOSED}」(后果,不是术语)\n{out}")
if "修复:" not in out:
failures.append(f"判据①:未就绪时必须给一行修复命令\n{out}")
def case_pinned_and_key(work: Path, failures: list[str], script: Path = SCRIPT) -> None:
"""② 副本里夹一条假 PinnedKey + 环境里是同一把钥 ⇒ exit 0(真实 trust.rs 一个字节都不碰)。"""
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "pinned/trust.rs", TRUST_TEMPLATE.format(body=body))
rc, out = run(["--trust", str(trust)], secret_json(), script=script)
if rc != 0:
failures.append(f"判据②:表非空 + 钥同源应 exit 0,实际 {rc}\n{out}")
missing = [label for label in LABELS if label not in out]
if missing:
failures.append(f"判据②:就绪时也要打印三行,缺 {missing}\n{out}")
if "就绪" not in out:
failures.append(f"判据②:就绪时必须明说「就绪」\n{out}")
if FAIL_CLOSED in out:
failures.append(f"判据②:就绪时不该出现 fail-closed 那段警告\n{out}")
# 不泄密:种子的十六进制在输出里一次都不许出现(公钥可以出现,它是公开物)。
if DUMMY_SEED in out:
failures.append(f"判据②:输出里出现了**种子**(私钥)—— 这是泄密\n{out}")
def case_key_mismatch(work: Path, failures: list[str]) -> None:
"""② 的对账:表里钉的是 A,环境里是 B ⇒ 未就绪(发布时 --check-pinned-keyring 会红)。"""
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "mismatch/trust.rs", TRUST_TEMPLATE.format(body=body))
rc, out = run(["--trust", str(trust)], secret_json(OTHER_SEED, "other-key"))
if rc != 1:
failures.append(f"②对账:钥不在表里应 exit 1,实际 {rc}\n{out}")
if "不在" not in out:
failures.append(f"②对账:必须明说这把钥不在信任表里\n{out}")
if DUMMY_SEED in out or OTHER_SEED in out:
failures.append(f"②对账:输出里出现了种子\n{out}")
def case_no_key(work: Path, failures: list[str]) -> None:
"""② 没有钥:即便表是好的,也算未就绪(签不出任何清单)。"""
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "nokey/trust.rs", TRUST_TEMPLATE.format(body=body))
rc, out = run(["--trust", str(trust)], None)
if rc != 1:
failures.append(f"②无钥:应 exit 1,实际 {rc}\n{out}")
if KEY_ENV not in out:
failures.append(f"②无钥:必须点名环境变量 {KEY_ENV}(换个人也知道该配什么)\n{out}")
def case_release_evidence(work: Path, failures: list[str]) -> None:
"""③ 只取证:有留档 / 有清单没签名 / 什么都没给 = 未知;且它不影响退出码。"""
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "release/trust.rs", TRUST_TEMPLATE.format(body=body))
signed = work / "release/signed"
write(signed / "manifest.json", '{"channel": "stable", "releases": []}\n')
write(signed / "manifest.json.sig", '{"format": 1, "signatures": []}\n')
rc, out = run(["--trust", str(trust), "--dist", str(signed)], secret_json())
if rc != 0 or "有 ——" not in out:
failures.append(f"③有:留档齐全时应写「有」且退出码仍是 0,实际 rc={rc}\n{out}")
bare = work / "release/bare"
write(bare / "manifest.json", '{"channel": "stable", "releases": []}\n')
rc, out = run(["--trust", str(trust), "--dist", str(bare)], secret_json())
if "没有" not in out:
failures.append(f"③无:只有 manifest.json 时必须点明「没有 .sig」\n{out}")
nowhere = work / "release/absent"
rc, out = run(["--trust", str(trust), "--dist", str(nowhere)], secret_json())
if "未知" not in out:
failures.append(f"③未知:取不到证据时必须写「未知」,不许写成「无」(本地没留档 ≠ 上次没签)\n{out}")
if rc != 0:
failures.append(f"③未知:③ 不参与就绪判定,退出码应仍是 0,实际 {rc}\n{out}")
def case_usage(work: Path, failures: list[str]) -> None:
"""用法错 = 2:参数写错、标记丢了(脚本形不成判断)。绝不能混进 1(那是「未就绪」的读数)。"""
rc, out = run(["--nonsense"], None)
if rc != 2:
failures.append(f"用法错:未知参数应 exit 2,实际 {rc}\n{out}")
broken = write(work / "broken/trust.rs", "pub(crate) const PRODUCTION: &[PinnedKey] = &[];\n")
rc, out = run(["--trust", str(broken)], None)
if rc != 2:
failures.append(f"用法错:标记缺失应 exit 2(不是 1),实际 {rc}\n{out}")
rc, out = run(["--trust", str(work / "no-such-dir/trust.rs")], None)
if rc != 2:
failures.append(f"用法错:trust.rs 不存在应 exit 2,实际 {rc}\n{out}")
def case_summary(work: Path, failures: list[str]) -> None:
"""`--line`:CI 用,把三行搬进 $GITHUB_STEP_SUMMARY;且必须写明「不是门禁」。"""
trust = write(work / "summary/trust.rs", TRUST_TEMPLATE.format(body=""))
summary = work / "summary-step.md"
rc, out = run(["--trust", str(trust), "--line"], None, summary=summary)
if rc != 1:
failures.append(f"summary:空表仍应 exit 1,实际 {rc}\n{out}")
if not summary.is_file():
failures.append("summary:给了 $GITHUB_STEP_SUMMARY 却没写文件")
return
text = summary.read_text(encoding="utf-8")
missing = [label for label in LABELS if label not in text]
if missing:
failures.append(f"summary:三行没全部进 summary,缺 {missing}\n{text}")
if "不代表签名就绪" not in text:
failures.append(f"summary:必须写清「绿色不代表签名就绪」(播报 ≠ 门禁)\n{text}")
TARGET_LINE = " return 0 if ready else 1\n"
def mutant(work: Path, name: str, replacement: str) -> Path | None:
"""造一份「只改了退出码那一行」的副本。副本自成一体(模块一起拷):脚本按 `__file__` 上一级找
`packaging/`,不靠 PYTHONPATH(实测本机 3.12 根本不把 PYTHONPATH 放进 sys.path)。"""
source = SCRIPT.read_text(encoding="utf-8")
if source.count(TARGET_LINE) != 1:
return None
root = work / name
path = write(root / "packaging/check_signing_ready_mutated.py", source.replace(TARGET_LINE, replacement))
for module in ("sign_manifest.py", "ed25519_ref.py"):
write(root / f"packaging/{module}", (ROOT / f"packaging/{module}").read_text(encoding="utf-8"))
write(root / "trust.rs", TRUST_TEMPLATE.format(body=""))
return path
def case_stray_hint(work: Path, failures: list[str]) -> None:
"""① 的「标记之外还有条目」提示:只**点名**,不替人断言「贴错了」。
实测抓到的误报:真实 `trust.rs` 里 `TEST_ONLY` 那一条**本来就在标记之外**(那是它的正确位置),
上一版措辞「另有 N 条写在标记之外(贴错位置,等于没钉)」会对今天的状态喊狼来了。
"""
outside = 'pub(crate) const TEST_ONLY: &[PinnedKey] = &[PinnedKey { key_id: "test-only-key", public_key_hex: "%s" }];\n'
trust = write(
work / "stray/trust.rs",
outside % ("ab" * 32) + TRUST_TEMPLATE.format(body=""),
)
rc, out = run(["--trust", str(trust)], None)
if rc != 1:
failures.append(f"①提示:标记外有条目时表仍是空的 ⇒ 应 exit 1,实际 {rc}\n{out}")
if "test-only-key" not in out:
failures.append(f"①提示:标记之外的条目必须**点名**(否则人不知道贴漏了哪一条)\n{out}")
if "TEST_ONLY" not in out:
failures.append(f"①提示:必须说清 `TEST_ONLY` 本来就在标记之外(否则这就是一句误报)\n{out}")
def case_key_unreadable(work: Path, failures: list[str]) -> None:
"""② `--key` 指向读不了的路径 ⇒ **2**(用法错),不是 1、也不是 traceback(r1 P2-2)。
`read_text` 曾在 `main()` 的 try 之外 ⇒ `FileNotFoundError` 直接抛成 rc=1,与「未就绪」同码。
脚本的全部价值是「退出码可信」:1 和 2 混了,「我路径写错了」会被读成「签名没配好」。
"""
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "badkey/trust.rs", TRUST_TEMPLATE.format(body=body))
directory = work / "badkey/a-directory"
directory.mkdir(parents=True, exist_ok=True)
for label, path in (
("不存在", work / "badkey/no-such.key"),
("是目录", directory),
):
rc, out = run(["--trust", str(trust), "--key", str(path)], None)
if rc != 2:
failures.append(f"--key({label})应 exit 2,实际 {rc}\n{out}")
if "Traceback" in out:
failures.append(f"--key({label})不许抛未捕获异常(traceback 会盖掉一行说明)\n{out}")
if "用法错" not in out:
failures.append(f"--key({label})必须说清这是用法错,不是「未就绪」\n{out}")
if LABELS[0] in out or LABELS[1] in out:
failures.append(f"--key({label})在形不成判断时不该打印三行读数(会被读成「读数」)\n{out}")
def case_withheld(work: Path, failures: list[str]) -> None:
"""② 非 main ref:写「未判定 + 原因」,**不写「没配」**(r1 P2-1 第三条)。
同一份空钥输入,有没有 `ULL_SIGNING_KEY_WITHHELD` 是两件事:「策略上不注入」不是「仓库没配置」。
"""
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "withheld/trust.rs", TRUST_TEMPLATE.format(body=body))
reason = "非 main ref(按设计不注入 Secret;要读这一行请对 main 跑)"
rc, out = run(["--trust", str(trust)], None, withheld=reason)
if rc != 1:
failures.append(f"未判定:这一趟没钥可读 ⇒ 不许声称就绪(应 exit 1),实际 {rc}\n{out}")
if "未判定" not in out or reason not in out:
failures.append(f"未判定:② 必须写「未判定」+ 原因串(否则会被读成「没配」)\n{out}")
if "未注入" not in out:
failures.append(f"未判定:② 必须写出「未注入」这个动作(「策略不注入」与「没配置」是两件事)\n{out}")
if "未设置" in out:
failures.append(f"未判定:非 main ref 上不许出现「未设置」(那是「没配」的措辞)\n{out}")
# 反方向:没有 withheld 时照样得说「未设置/为空」,别把两档并成一档。
rc, out = run(["--trust", str(trust)], None)
if "未设置" not in out:
failures.append(f"未判定:没有 withheld 时仍应报「未设置/为空」(两档不许合并)\n{out}")
def case_section_refs(work: Path, failures: list[str]) -> None:
"""输出里出现的每个 `§N` 都必须是 runbook **真实存在**的章节号(r1 P2-3)。
上一版把「贴 PinnedKey」指到 §3(那里讲发布时的执行顺序)、把「发布后回验」指到 §4(那里是已知坑)。
本文件的交付物就是「一行可照抄的指针」,所以指针要机器校验。
"""
sections = set(re.findall(r"^##\s*(\d+)\.", RUNBOOK.read_text(encoding="utf-8"), re.MULTILINE))
if not sections:
failures.append("指针:runbook 里一个 `## N.` 标题都没解析到(章节号格式变了?)")
return
body = f' PinnedKey {{ key_id: "selftest-key", public_key_hex: "{DUMMY_PUB}" }},\n'
trust = write(work / "pointer/trust.rs", TRUST_TEMPLATE.format(body=body))
empty = write(work / "pointer/empty.rs", TRUST_TEMPLATE.format(body=""))
seen: set[str] = set()
for args, key in (
(["--trust", str(trust)], secret_json()), # 就绪:结论指向步⑤ / §5
(["--trust", str(trust)], None), # ② 没钥:指向步②
(["--trust", str(empty)], None), # ① 空表:修复行指向步①–③
):
seen |= set(re.findall(r"§(\d+)", run(args, key)[1]))
if not seen:
failures.append("指针:一条 `§N` 都没从输出里抓到(用例失效了)")
unknown = sorted(seen - sections)
if unknown:
failures.append(f"指针:输出里指向了 runbook 没有的章节 {unknown}(真实章节:{sorted(sections)})")
def case_mutation(work: Path, failures: list[str]) -> None:
"""变异重放:把被测对象改坏,**同一组断言原样套上去必须报红**(两个方向)。
(接线/ref 门那一侧的变异在 `signing_ready_wiring_selftest.py`。)"""
# ── 变异 A:退出码永远 0(「未就绪」伪装成「就绪」)⇒ 判据① 必须红 ──
always_zero = mutant(work, "mutant-zero", " return 0\n")
if always_zero is None:
failures.append(f"变异 A:{SCRIPT.name} 里找不到唯一的 `{TARGET_LINE.strip()}`,变异无处下手(脚本形状变了就更新这里)")
else:
trust = work / "mutant-zero/trust.rs"
probe: list[str] = []
case_empty_table(work / "mutant-zero", probe, script=always_zero)
# 副本必须真的「跑得起来、只有退出码变了」:崩溃(rc=2)也会让 probe 非空,那就变成假绿了。
rc, out = run(["--trust", str(trust)], None, script=always_zero)
if rc != 0:
failures.append(f"变异 A:副本在空表上应返回 0(这就是变异本身),实际 {rc}\n{out}")
missing = [label for label in LABELS if label not in out]
if missing:
failures.append(f"变异 A:副本连三行读数都没打印(缺 {missing}),它不是在测退出码那一条\n{out}")
if not probe:
failures.append(f"变异 A:「退出码永远 0」的副本竟然让判据①全过 —— 断言抓不住改绿(副本空表返回 {rc})")
elif not any("exit 1" in item for item in probe):
failures.append(f"变异 A:副本被判红,但不是因为退出码那一条 —— 变异用例没对准判据①:{probe}")
# ── 变异 B:退出码永远 1(「就绪」伪装成「未就绪」)⇒ 判据② 必须红 ──
# 反向也要试:只测一个方向的话,「就绪的正路根本不存在」这种回归仍然全绿。
always_one = mutant(work, "mutant-one", " return 1\n")
if always_one is None:
failures.append(f"变异 B:{SCRIPT.name} 里找不到唯一的 `{TARGET_LINE.strip()}`")
else:
probe = []
case_pinned_and_key(work / "mutant-one", probe, script=always_one)
if not probe:
failures.append("变异 B:「退出码永远 1」的副本竟然让判据②全过 —— 就绪的正路没被断言(假绿的反面)")
elif not any("exit 0" in item for item in probe):
failures.append(f"变异 B:副本被判红,但不是因为退出码那一条 —— 变异用例没对准判据②:{probe}")
def main() -> int:
failures: list[str] = []
with tempfile.TemporaryDirectory(prefix="ull-signing-ready-") as temp:
work = Path(temp)
case_empty_table(work, failures)
case_pinned_and_key(work, failures)
case_key_mismatch(work, failures)
case_no_key(work, failures)
case_release_evidence(work, failures)
case_stray_hint(work, failures)
case_key_unreadable(work, failures)
case_withheld(work, failures)
case_section_refs(work, failures)
case_usage(work, failures)
case_summary(work, failures)
case_mutation(work, failures)
for line in failures:
print(f"FAIL {line}", file=sys.stderr)
print("signing-ready selftest:", "ok" if not failures else f"{len(failures)} 处失败")
return 0 if not failures else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""`signing-status.yml` 的**接线**自测(M255 r1 从 `signing_ready_selftest.py` 分出)。
拆成两份的唯一原因是全仓 400 行红线(`guards.yml` 的 `file-size`):判据本体(三行读数 / `0/1/2` /
两个方向的退出码变异)留在 `signing_ready_selftest.py`,这里只管**接线与文案里的指针**。
钉住的判据(每条都对应一次真实会咬人的回归):
| 判据 | 抓什么 |
|---|---|
| 不是门禁 | 没有别的 workflow `uses:` 它;全文无 error 级注解;就绪度那步抓 `rc=$?` 后 `exit 0`;文件头两句口径都在 |
| **生产种子只在 main 注入** | 注入行必须是 `github.ref == 'refs/heads/main' && secrets.<KEY_ENV>` 这个条件式;`on:` 块里**不许出现 `pull_request`**;非 main 另有 `ULL_SIGNING_KEY_WITHHELD` 说明原因(于是 ② 读「未判定」而不是「没配」) |
| 文档指针真的存在 | `signing-runbook.md` / `signing.md` 里出现的 `packaging/…`、`crates/…`、`.github/…` 路径必须真在仓库里(r1 P2-3:`signing-ready.yml` 那个文件名根本不存在) |
| 变异必须红 | ①去掉 ref 门 ②加回 `pull_request` ③把文档里一个路径改坏 —— 三处各自重放,**对应用例必须报红** |
为什么要 ref 门(这是本文件存在的最重要理由):生产签名种子能给**全量已安装客户端**签出被信任的清单
(自更新链 = 用户端 RCE)。仓库内分支的 PR 与任意分支的 `workflow_dispatch` 都**拿得到**仓库 Secret
(只有 fork / Dependabot 拿不到),而 CI 会在**那个 PR 自己的代码**上执行 checker。既有设计把种子锁在
tag ref 后面(`release.yml` 的 `if: … github.ref_type == 'tag'`),播报文件不许在旁边开一条新路。
python3 packaging/tests/signing_ready_wiring_selftest.py
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
WORKFLOW_DIR = ROOT / ".github/workflows"
WORKFLOW = WORKFLOW_DIR / "signing-status.yml"
RUNBOOK = ROOT / "packaging/docs/signing-runbook.md"
SIGNING_MD = ROOT / "packaging/docs/signing.md"
sys.path.insert(0, str(ROOT / "packaging"))
# 环境变量名从 checker 取,不写第二份字面量:两处漂开时,「ref 门注入了 A、checker 读 B」会静默失效。
from check_signing_ready import KEY_ENV, WITHHELD_ENV # noqa: E402
MAIN_REF = "refs/heads/main"
GATE = f"github.ref == '{MAIN_REF}' && secrets.{KEY_ENV}"
#: 文档里允许出现的「路径」形状:仓库根的四个前缀(相对路径如 `docs/x.md` 不在此列,避免误判)。
PATH_RE = re.compile(r"(?:packaging|crates|\.github|\.cargo)/[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)*")
def on_block(text: str) -> str:
"""取 `on:` 那一段(到下一个顶格键为止),并**去掉注释行**——注释里会正当地提到 `pull_request`。"""
lines = text.splitlines()
start = next((i for i, line in enumerate(lines) if re.match(r"^on:\s*$", line)), None)
if start is None:
return ""
body: list[str] = []
for line in lines[start + 1 :]: # 跳过 `on:` 那一行本身(它顶格,会让下面的 break 立刻触发)
if line.startswith(" ") or not line:
if not line.strip().startswith("#"):
body.append(line)
continue
break
return "\n".join(body)
def check_workflow(text: str) -> list[str]:
"""返回该 workflow 文本没过的接线判据(空 = 通过)。变异用例把同一函数套在改坏的副本上。"""
problems: list[str] = []
for phrase in ("播报,不是门禁", "不应被设为 required"):
if phrase not in text:
problems.append(f"文件头必须写清「{phrase}」(不阻断要是可核的事实,不是靠没接线)")
if "::error::" in text:
problems.append("播报 workflow 里不许出现 error 级注解(会让人以为构建挂了,与播报定位冲突)")
if "rc=$?" not in text or "exit 0" not in text:
problems.append("就绪度那一步必须抓 `rc=$?` 后 `exit 0`(未就绪不许让 job 变红)")
# ── 安全口径:生产签名种子只在 main 注入 ──
if GATE not in text:
problems.append(f"注入行必须是 `{GATE} || ''` 这个条件式(去掉 ref 门 = 把种子交给任意分支的代码)")
if f"{WITHHELD_ENV}:" not in text:
problems.append(f"非 main 分支必须给 `{WITHHELD_ENV}` 一个原因串(否则 ② 会报假的「没配」)")
if "pull_request" in on_block(text):
problems.append("`on:` 块里不许出现 `pull_request`(仓库内分支的 PR 拿得到 Secret,而 CI 跑的是 PR 自己的代码)")
return problems
def wired_elsewhere() -> list[str]:
"""扫全部 workflow:有人 `uses:` 本文件 ⇒ 播报变成了别人的必需检查。"""
hits: list[str] = []
for path in sorted(WORKFLOW_DIR.glob("*.y*ml")):
if path.resolve() == WORKFLOW.resolve():
continue
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
if re.match(r"^\s*uses:\s*", line) and WORKFLOW.name in line:
hits.append(f"{path.relative_to(ROOT).as_posix()}:{number}: {line.strip()}")
return hits
def check_doc_paths(text: str, where: str) -> list[str]:
"""文档里出现的仓库路径必须真实存在(r1 P2-3:指针错会把人送到无关文件/章节)。"""
problems: list[str] = []
for token in sorted(set(PATH_RE.findall(text))):
if not (ROOT / token).exists():
problems.append(f"{where} 里写了不存在的路径:{token}")
return problems
def case_wiring(failures: list[str]) -> None:
"""真实文件的接线判据 + 文档路径存在性。"""
if not WORKFLOW.is_file():
failures.append(f"接线:{WORKFLOW} 不存在")
return
text = WORKFLOW.read_text(encoding="utf-8")
failures.extend(f"接线:{item}" for item in check_workflow(text))
hits = wired_elsewhere()
if hits:
failures.append("接线:有 workflow `uses:` 了 " + WORKFLOW.name + " ⇒ 播报变成了别人的必需检查:\n " + "\n ".join(hits))
for doc in (RUNBOOK, SIGNING_MD):
failures.extend(f"指针:{item}" for item in check_doc_paths(doc.read_text(encoding="utf-8"), doc.name))
def case_mutation(failures: list[str]) -> None:
"""变异重放:把接线改坏,**同一组判据原样套上去必须报红**(r1 收尾要求的第 ④ 项)。"""
real = WORKFLOW.read_text(encoding="utf-8")
if GATE not in real:
failures.append("变异:真实文件里就没有 ref 门,先修 P2-1 再谈变异")
return
vectors = [
(
"去掉 ref 门(种子对任意 ref 可见)",
real.replace(GATE, f"secrets.{KEY_ENV}"),
"ref 门",
),
(
"加回 pull_request 触发",
real.replace(" workflow_dispatch:\n", " pull_request:\n workflow_dispatch:\n", 1),
"pull_request",
),
(
"去掉非 main 的 WITHHELD 说明",
"\n".join(line for line in real.splitlines() if WITHHELD_ENV not in line) + "\n",
WITHHELD_ENV,
),
]
for label, mutated, expect in vectors:
if mutated == real:
failures.append(f"变异「{label}」没改到任何字节(变异无处下手,用例什么都没证明)")
continue
problems = check_workflow(mutated)
if not any(expect in item for item in problems):
failures.append(f"变异「{label}」没有被抓:期望命中「{expect}」,实际 {problems or '零问题'}")
# 文档指针的变异:把 runbook 里一个真实路径改成不存在的名字,同一判据必须报红。
runbook = RUNBOOK.read_text(encoding="utf-8")
broken = runbook.replace("packaging/docs/signing.md", "packaging/docs/signing-oldext.md", 1)
if broken == runbook:
failures.append("变异「文档路径」:runbook 里找不到可替换的路径,用例失效")
elif not check_doc_paths(broken, "mutated-runbook"):
failures.append("变异「文档路径」:改成不存在的路径后判据仍然是绿的 —— 路径存在性没被判")
def main() -> int:
failures: list[str] = []
case_mutation(failures)
case_wiring(failures)
for line in failures:
print(f"FAIL {line}", file=sys.stderr)
print("signing-status wiring selftest:", "ok" if not failures else f"{len(failures)} 处失败")
return 0 if not failures else 1
if __name__ == "__main__":
raise SystemExit(main())