fix(packaging): 播报 workflow 的种子只在 main 注入 + --key 用法错回 2 + 指针修正(M255 r1 P2-1/2/3)

P2-1(安全):`signing-status.yml` 原先把生产签名种子注入到 `pull_request` 与任意 ref 的
`workflow_dispatch` 运行里,而 CI 跑的是那个 ref 自己的代码 ⇒ 任何能推分支的人都能把种子搬走
(这把种子能给全量已装客户端签可信清单)。改成:
  - 去掉 `pull_request` 触发;
  - 注入行加 ref 门:`github.ref == 'refs/heads/main' && secrets.… || ''`;
  - 非 main 用 `ULL_SIGNING_KEY_WITHHELD` 说明原因,于是 ② 读「未判定(未注入)」而不是「没配」。
runbook §2 步② 加硬顺序:先合这笔、再录 Secret。

P2-2:`--key` 读不了(不存在 / 是目录 / 非 UTF-8)曾在 main 的 try 之外抛 traceback + rc=1,
与「未就绪」同码;现在统一 rc=2 + 一行说明,并补用例 `key_unreadable`。

P2-3:4 处文案指针修正(`signing-ready.yml` → `signing-status.yml`;§3 → §2 步③;§1–§3 →
§2 步①–③;§4 → §2 步⑤ / §5)。新增两条机检:`section_refs`(输出的 `§N` 必须是 runbook 真实
章节号)与 `signing_ready_wiring_selftest.py`(文档里的 `packaging/…`/`crates/…`/`.github/…`
路径必须真存在),后者另带三处变异重放:去掉 ref 门 / 加回 pull_request / 改坏文档路径 ⇒ 必须红。

顺带修掉 r1 复审指出的两处小问题:`--release-assets` 里只有 `manifest.json.sig` 时的子串假读数
(先剥 `.sig` 再判清单)、③ 那个从不参与判定的 bool 保持原样(无行为影响)。

selftest 拆成两份(400 行红线):判据本体 + 接线/文案。guards.yml 仍 399 行零改动。
This commit is contained in:
AskaEth committed 2026-11-11 20:17:04 +08:00
1 parent bff48e27fa
commit bc1b09b776
6 files changed
+397 -86

No files matched your search

+24 -8
View File
@@ -9,7 +9,7 @@ name: Signing status(播报,不是门禁)
# 清单(fail-closed,用户端表现为「更新永远失败」)。工具链早就齐了,缺的是**可见性**:这三行读数让
# 「今天差什么」在每次运行里都摆在台面上,而不是等发版时才发现。
#
# 口径三条(由 `packaging/tests/signing_ready_selftest.py` 的接线判据机器校验,不是靠注释自律):
# 口径四条(由 `packaging/tests/signing_ready_wiring_selftest.py` 的接线判据机器校验,不是靠注释自律):
# ① 就绪度那一步抓 `rc=$?` 之后**恒 `exit 0`** —— 未就绪用 `::warning::` 播报,不用 error 级注解
# (error 会让人以为构建挂了,与播报定位直接冲突)。真正的红留在 `release-publish.yml` 的签名步:
# 那里今天必然红是**对的**,因为签不出来的产物本来就不能发。
@@ -17,6 +17,15 @@ name: Signing status(播报,不是门禁)
# ③ 本文件**刻意不声明 `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`。
@@ -24,7 +33,7 @@ name: Signing status(播报,不是门禁)
on:
push:
branches: [main]
pull_request:
# ⚠ 刻意**没有** `pull_request`(见口径④)。要读 PR 的签名就绪度,就跑 push 到分支或对着 main dispatch。
workflow_dispatch:
permissions:
@@ -39,8 +48,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 三行读数 + 0/1/2 语义 + 「退出码改绿必须被抓到」+ 接线不是门禁
- 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
@@ -78,11 +91,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ② 要读「环境里有没有钥」,所以把 Secret 传进来(与 release-publish.yml 的做法一致)。
# 脚本只回显 key_id 与**公钥**,种子(seed_hex)一个字节都不打印 —— 用例 no_seed_leak 钉着这条。
# ② 要读「环境里有没有钥」,所以把 Secret 传进来 —— **只在 main ref 上**(口径④)。
# 门是下面这两行 env(同一个条件),不是 `if:`:这样非 main 的运行照样跑完三行读数,
# 只是 ② 读成「未判定」,而不是整个 job 被跳过后一片空白。
# ⚠ 这两行是安全边界,接线自检钉着它们(去掉 ref 门 / 加回 pull_request 都会当场红)。
- name: 三行就绪度(脚本给 0/1/2;本步恒 exit 0)
env:
ULL_MANIFEST_SIGNING_KEY: ${{ secrets.ULL_MANIFEST_SIGNING_KEY }}
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
@@ -92,14 +108,14 @@ jobs:
cat "$RUNNER_TEMP/signing-ready.txt"
case "$rc" in
0) echo "::notice::签名就绪:信任表非空,且环境里的钥与它同源。这是一条播报,不是门禁。" ;;
1) echo "::warning::签名未就绪:今天任何正式二进制都验不过任何清单(fail-closed)。该做什么见 packaging/docs/signing-runbook.md §2 的下单清单。" ;;
1) echo "::warning::签名未就绪(或 ② 未判定):该做什么见 packaging/docs/signing-runbook.md §2 的下单清单。" ;;
*) echo "::warning::就绪度脚本用不了(rc=$rc,用法错):这是尺子的问题,不是签名状态 —— 看上面的 stderr。" ;;
esac
{
echo
echo "- 本 workflow 是**播报**,不是门禁:**不应被设为 required**,也不阻断任何其它 job。"
echo "- 颜色 ≠ 结论:绿色只说明脚本跑完了;结论只看上面三行(尤其 ①「信任表」)。"
echo "- fork / Dependabot 的 PR 运行拿不到仓库 Secret ⇒ ② 必然是「未就绪」;以 main 上的运行为准。"
echo "- **Secret 只在 \`refs/heads/main\` 上注入**(生产签名种子锁在 main / tag 后面);非 main ref 的 ② 显示「未判定」而不是「没配」。"
echo "- 真门禁在哪:\`release-publish.yml\` 的签名步(\`--check-pinned-keyring\` / \`--sign\` / \`--verify-pinned\`)。"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
+3 -2
View File
@@ -82,8 +82,9 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
| `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`)+ 不泄种子 / ② 对账 / ③ 有-无-未知 / 用法错 = 2 / `--line` 写 summary / **两个方向的变异重放**(「退出码永远 0」⇒ 判据①必须红;「永远 1」⇒ 判据②必须红)/ **接线判据**:没有别的 workflow `uses:` 它、文件里没有 error 级注解、就绪度那步 `rc=$?` 后 `exit 0`。不覆盖:整个 workflow 被删后没人跑它(与 `gpu_shot_ci_selftest.py` 同一条边界) | ≤400 |
| `.github/workflows/signing-status.yml` | 就绪度**播报**接线(M255):把三行搬进 job summary。**不是门禁**:未就绪用 `::warning::`、就绪度那步**恒 `exit 0`**、**不应被设为 required**、刻意不声明 `workflow_call:`(别人没法 `uses:` 它,接线自检另外扫全仓防「顺手接进 dev 线」)。同 job 另有一步跑上面的自测(那一步会红 = 尺子坏了,与「未就绪」分开写)。新开文件而不是并进 `guards.yml`:那份已 399/400 行 | ≤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 |
+60 -16
View File
@@ -19,10 +19,15 @@
「别再造假绿的尺子」的血泪账直接冲突。公钥是公开物,回显它是安全的;**种子(`seed_hex`)在本脚本里
一次都不打印**。
退出码是对外接口(改动即行为变化,`packaging/tests/signing_ready_selftest.py` 钉着它):
`0` 就绪 / `1` 未就绪 / `2` 用法错(含 trust.rs 读不到、标记丢了 —— 那时脚本形不成判断,不是「未就绪」)。
②还有第三种读数:**未判定**。CI 的播报 workflow 刻意只在 `main` ref 上注入 Secret(生产签名种子等于
「给全量已装客户端签可信清单」的能力,既有设计把它锁在 tag ref 后面),非 main ref 的运行里这一行会写
「未判定 —— 非 main ref(按设计不注入)」,**不写「没配」**:把「策略不注入」说成「没配」就是一次假读数。
**它不是门禁**:CI 里的 `.github/workflows/signing-ready.yml` 只把这三行搬进 job summary,
退出码是对外接口(改动即行为变化,`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
@@ -41,6 +46,10 @@ 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"))
@@ -82,15 +91,29 @@ def table_state(path: Path) -> tuple[bool, str, list[tuple[str, str]]]:
return False, f"未就绪 —— 标记之间 0 条 PinnedKey{hint}({path}:{line_no} 起)", []
def key_state(key_file: str | None, pinned: list[tuple[str, str]]) -> tuple[bool, str, str | None]:
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` 与公钥十六进制。
"""
raw = Path(key_file).read_text(encoding="utf-8") if key_file else os.environ.get(KEY_ENV, "")
source = f"--key {key_file}" if key_file else f"环境变量 {KEY_ENV}"
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)
@@ -122,8 +145,10 @@ def release_state(dist: Path | None, assets: Path | None) -> tuple[bool | None,
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")
has_manifest = "manifest.json" in text
has_sig = f"manifest.json.sig" in text
# 先剥掉 `.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:
@@ -135,17 +160,22 @@ def release_state(dist: Path | None, assets: Path | None) -> tuple[bool | None,
def fix_hint(table_ok: bool, key_ok: bool, paste: str | None) -> str:
"""未就绪时的**一行**修复命令(不带任何密钥值;钥的事只指向 Secret 名与手册)。"""
"""未就绪时的**一行**修复命令(不带任何密钥值;钥的事只指向 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} §3)"
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} §1–§3)"
f"→ 私钥贴进 Secret {KEY_ENV},打印的 PinnedKey 那行贴进 trust.rs 标记之间(手册 {RUNBOOK} §2 步①–③)"
)
if not key_ok and paste:
return f"修复:把这一行贴进 {TRUST_RS.name} 的 PINNED-KEYS 标记之间 —— {paste}(手册 {RUNBOOK} §3)"
return f"修复:把密钥贴进仓库 Secret {KEY_ENV}(内容 = --generate-key 打印的那行 JSON;手册 {RUNBOOK} §2)"
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:
@@ -162,8 +192,17 @@ def main(argv: list[str] | None = None) -> int:
except (OSError, UnicodeDecodeError, RuntimeError) as error:
print(f"error: 读不了信任表:{error}(脚本无法形成判断,按用法错退出)", file=sys.stderr)
return 2
key_ok, key_line, paste = key_state(args.key, pinned)
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),
@@ -175,7 +214,11 @@ def main(argv: list[str] | None = None) -> int:
ready = table_ok and key_ok
if ready:
print(f"结论:就绪({args.trust} 里钉了钥,环境里的钥与它同源)—— 可以按 {RUNBOOK} §4 发一次带 .sig 的 Release 并用 --verify-pinned 回验")
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}")
@@ -184,11 +227,12 @@ def main(argv: list[str] | None = None) -> int:
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"- 结论:**{'就绪' if ready else '未就绪'}**",
f"- 结论:**{verdict}**",
f"- 本 job 的绿色**不代表签名就绪**:判据只看上面三行字,退出码恒为 0(见 `{RUNBOOK}` §6)。",
]
Path(summary).write_text("\n".join(body) + "\n", encoding="utf-8")
+30 -9
View File
@@ -42,11 +42,20 @@ job summary 里(见 §6 —— 它是播报,不是门禁)。
下面每一行都只有命令与占位符,**没有任何密钥值**。做完一步就把该步的验证跑一遍。
> 🔴 **顺序硬要求(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:
# ② 把打印出来的那一行 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 历史里)
@@ -106,8 +115,9 @@ sign_manifest.py --verify-pinned dist/manifest.json # 用钉住的公钥回验
6. **`set -x` / `echo $ULL_MANIFEST_SIGNING_KEY`**:一次就够把私钥泄进日志。工作流里那一步显式 `set +x`
且只回显**公钥**;本机调试同理,别开 trace。
7. **`--generate-key` 的输出落进文件/issue/聊天**:当成泄露处理,走 `signing.md` §5 轮换(旧钥签新钥)。
8. **fork / Dependabot 的 PR 拿不到仓库 Secret**:那种运行里 `check_signing_ready.py` 的 ② 必然是
「未就绪」,这是**环境限制**,不是仓库状态。以 `main` 上的读数为准。
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` 那条命令就是干这个的)。
@@ -115,6 +125,9 @@ sign_manifest.py --verify-pinned dist/manifest.json # 用钉住的公钥回验
并且至少一个发布周期的双签名之后(`signing.md` §5)。
11. **「昨天验过」不等于「今天验得过」**:`PRODUCTION` 是**编译期**常量,改 `trust.rs` 只影响**下一次构建**。
已在用户盘上的旧二进制永远用旧表 —— 这是设计,不是 bug。
12. **给播报 workflow 加回 `pull_request` 触发、或去掉注入行的 ref 门**:等于把生产签名种子交给任意推
分支的人的代码(自更新链 = 用户端 RCE)。这两处各有接线用例钉着(去掉任一处都会红),
别为了「PR 上也能看读数」去动它 —— 看读数请对 `main` 跑,或读 §1 在本地跑。
## 5. 发布后回验(发布侧,不需要私钥)
@@ -132,16 +145,21 @@ python3 packaging/sign_manifest.py --verify-pinned /tmp/verify/manifest.json
## 6. CI 里那条读数:`Signing status(播报)`
`.github/workflows/signing-status.yml` 每天在 `main` / PR / 手动 dispatch 上跑一次,把 §1 的三行搬进
job summary。**它不是门禁**:
`.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`:那一步**会红**,红 = **尺子坏了**
(三行读数/退出码/变异重放/接线判据之一没过),与「签名未就绪」是两件不同的事,措辞在 summary 里分开写。
* 同一 workflow 里另有两步跑自测(`packaging/tests/signing_ready_selftest.py` +
`packaging/tests/signing_ready_wiring_selftest.py`):那两步**会红**,红 = **尺子坏了**(三行读数/退出码/
变异重放/接线与 ref 门之一没过),与「签名未就绪」是两件不同的事,措辞在 summary 里分开写。
为什么不做成门禁:今天已经有四次「守卫假绿」的账(见 `packaging/README.md` §12 里那几条先例)。
把一条**按定义今天必然失败**的检查做成 required,只会有两种结局 —— 要么所有人学会忽略红叉,
@@ -157,13 +175,16 @@ job summary。**它不是门禁**:
②③ 两步要动 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` | 判据自测:三行/退出码/两个方向的变异重放/接线不是门禁 |
| `.github/workflows/signing-status.yml` | 把三行搬进 job summary 的**播报** workflow(不是门禁) |
| `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 向量) |
+111 -51
View File
@@ -5,24 +5,23 @@
它就从**播报**变成了本仓栽过四次的**假绿尺子** —— 而且没人会当场发现(输出看起来一模一样)。
所以这里的第一类用例判的是退出码语义,第二类用例判的是**把退出码改绿之后自测会红**(变异重放)。
钉住的判据(每一条都对应 mission 的验收项):
| 用例 | 抓什么 |
|---|---|
| `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 |
| 判据 | 用例 | 抓什么 |
|---|---|---|
| ① 表空 ⇒ `exit 1` 且三行都打印 | `empty_table` | 今天的状态(`PRODUCTION` 空表)必须读成「未就绪」,且不许少打印第 ③ 行 |
| ② 副本夹一条假 `PinnedKey` ⇒ `exit 0` | `pinned_and_key` | 就绪的正路真的存在(**不碰真实 `trust.rs`**:全部走 `--trust <临时副本>`) |
| 变异:把退出码改成永远 0 ⇒ 用例必须红 | `mutation_always_zero` | 上面 ① 的期望值不被「永远绿」满足 ⇒ 自测当场报红 |
| 三行之外的读数 | `key_mismatch` / `no_key` / `release_*` | ② 的对账(钥不在表里)、③ 的有/无/未知 |
| 不泄密 | `no_seed_leak` | 种子的十六进制在 stdout/stderr 里一次都不出现 |
| 用法错 = 2 | `markers_missing` / `usage` | 标记丢了、参数写错 ⇒ 2,不是 1 |
| 接线不是门禁 | `wiring` | 没有其它 workflow `uses:` 它;文件里没有 `::error::`;就绪度那步 `exit 0` |
接线(不是门禁 / 不被 `uses:` / **ref 门** / 文档路径存在性)在
**`packaging/tests/signing_ready_wiring_selftest.py`** —— 拆成两份只因为全仓 400 行红线。
两份都不覆盖「整个 workflow 被删掉」(守门人住在被守对象旁边,与 `gpu_shot_ci_selftest.py` 同一条边界)。
**不覆盖(如实记)**:本文件自己也是「守门人住在被守对象旁边」——整个 `signing-status.yml` 被删掉后
没有任何东西会跑这份自测(与 `gpu_shot_ci_selftest.py` 同一条边界)。它也不判「用户机器上真的能更新」,
那是 `cargo test -p unlucky-core --lib update::tests::signature_cases` 与一次真 Release 的事。
公开的测试种子:`DUMMY_SEED` 是**由一句固定文本哈希出来的**假种子(同 `trust.rs` 里 `TEST_ONLY` 的那把
公开测试钥同一路数),只用来造读数,签不出任何正式清单;文档与本文件都不含任何真实私钥。
`DUMMY_SEED` 是**由一句固定文本哈希出来的**假种子(同 `trust.rs` 里 `TEST_ONLY` 那把公开测试钥同一路数),
只用来造读数,签不出任何正式清单;文档与本文件都不含任何真实私钥。
python3 packaging/tests/signing_ready_selftest.py
"""
@@ -39,9 +38,9 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent.parent
SCRIPT = ROOT / "packaging/check_signing_ready.py"
WORKFLOW_DIR = ROOT / ".github/workflows"
WORKFLOW = WORKFLOW_DIR / "signing-status.yml"
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", "② 环境签名钥", "③ 最近一次发布痕迹")
@@ -70,15 +69,27 @@ def write(path: Path, text: str) -> Path:
return path
def run(args: list[str], env_key: str | None, summary: Path | None = None, script: Path = SCRIPT) -> tuple[int, str]:
"""跑一次脚本,返回 `(退出码, stdout+stderr)`。`env_key` = 塞进 `KEY_ENV` 的内容(`None` = 不设)。"""
env = {k: v for k, v in os.environ.items() if k != KEY_ENV}
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(
@@ -249,11 +260,84 @@ def case_stray_hint(work: Path, failures: list[str]) -> None:
failures.append(f"①提示:必须说清 `TEST_ONLY` 本来就在标记之外(否则这就是一句误报)\n{out}")
def case_mutation(work: Path, failures: list[str]) -> None:
"""变异重放:改坏被测对象,**同一组断言原样套上去必须报红**(两个方向都试)。
def case_key_unreadable(work: Path, failures: list[str]) -> None:
"""② `--key` 指向读不了的路径 ⇒ **2**(用法错),不是 1、也不是 traceback(r1 P2-2)。
这是本 mission 唯一认可的形状 —— 不是「我写了断言」,而是「把对象改坏,断言真的会红」。
`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:
@@ -288,32 +372,6 @@ def case_mutation(work: Path, failures: list[str]) -> None:
failures.append(f"变异 B:副本被判红,但不是因为退出码那一条 —— 变异用例没对准判据②:{probe}")
def case_wiring(failures: list[str]) -> None:
"""接线判据:它**只是播报**。别人 `uses:` 它 = 它成了某条线的必需检查 ⇒ 红。"""
if not WORKFLOW.is_file():
failures.append(f"接线:{WORKFLOW} 不存在")
return
text = WORKFLOW.read_text(encoding="utf-8")
for phrase in ("播报,不是门禁", "不应被设为 required"):
if phrase not in text:
failures.append(f"接线:文件头必须写清「{phrase}」(不阻断要是可核的事实,不是靠没接线)")
if "::error::" in text:
failures.append("接线:播报 workflow 里不许出现 `::error::`(那会让人以为构建挂了,与播报定位冲突)")
if "rc=$?" not in text or "exit 0" not in text:
failures.append("接线:就绪度那一步必须抓 `rc=$?` 后 `exit 0`(未就绪不许让 job 变红)")
wired: 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:
wired.append(f"{path.relative_to(ROOT).as_posix()}:{number}: {line.strip()}")
if wired:
failures.append(
"接线:有 workflow `uses:` 了 " + WORKFLOW.name + " ⇒ 播报变成了别人的必需检查:\n " + "\n ".join(wired)
)
def main() -> int:
failures: list[str] = []
with tempfile.TemporaryDirectory(prefix="ull-signing-ready-") as temp:
@@ -324,10 +382,12 @@ def main() -> int:
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)
case_wiring(failures)
for line in failures:
print(f"FAIL {line}", file=sys.stderr)
@@ -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())