feat(packaging): 签名就绪度播报 + 操作手册(M255)

M254 查出 `trust.rs` 的 `PRODUCTION` 表在 PINNED-KEYS 标记之间是空的 ⇒ 任何正式二进制都验不过
任何清单(fail-closed)。工具链早就齐了,缺的是操作步骤与可见性;本条补的就是这两样,不生成钥、
不改 Rust、不碰 release-publish 的判据。

- `packaging/check_signing_ready.py`(只读、零第三方依赖):三行读数 ① 信任表空不空 ② 环境有没有钥
  (顺手对账「这把钥在不在表里」,种子一个字节都不打印)③ 最近一次发布的 .sig/清单痕迹(取不到写
  「未知」)。退出码 0 就绪 / 1 未就绪(打印 fail-closed 后果 + 一行修复命令)/ 2 用法错。
- `packaging/tests/signing_ready_selftest.py`:判据自测,含**两个方向的变异重放**(退出码永远 0 ⇒
  判据①必须红;永远 1 ⇒ 判据②必须红)与接线判据(没人 uses: 它、没有 error 级注解、就绪度那步恒 exit 0)。
- `packaging/docs/signing-runbook.md`:人类照做的 5 步 + 每步判据 + 11 条已知坑 + 没验的部分(无任何密钥值)。
- `.github/workflows/signing-status.yml`:把三行搬进 job summary 的**播报** workflow。guards.yml 已
  399/400,与 gpu-evidence.yml 同先例另开文件;刻意不声明 workflow_call:,未就绪用 ::warning::、
  就绪度那步恒 exit 0,明确「不应被设为 required」。
- `packaging/README.md` / `packaging/docs/signing.md`:索引与指针。
This commit is contained in:
AskaEth committed 2026-11-11 20:05:45 +08:00
1 parent fbb663c87c
commit bff48e27fa
6 files changed
+828

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
#!/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`)在本脚本里
一次都不打印**。
退出码是对外接口(改动即行为变化,`packaging/tests/signing_ready_selftest.py` 钉着它):
`0` 就绪 / `1` 未就绪 / `2` 用法错(含 trust.rs 读不到、标记丢了 —— 那时脚本形不成判断,不是「未就绪」)。
**它不是门禁**:CI 里的 `.github/workflows/signing-ready.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)"
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 key_state(key_file: str | None, 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():
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")
has_manifest = "manifest.json" in text
has_sig = f"manifest.json.sig" in text
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 名与手册)。"""
if not table_ok and paste:
return f"修复:把这一行贴进 {TRUST_RS.name} 的 PINNED-KEYS 标记之间 —— {paste}(手册 {RUNBOOK} §3)"
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)"
)
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)"
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
key_ok, key_line, paste = key_state(args.key, pinned)
release_ok, release_line = release_state(args.dist, args.release_assets)
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} §4 发一次带 .sig 的 Release 并用 --verify-pinned 回验")
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:
body = ["## 签名就绪度(M255,播报不是门禁)", ""]
body += [f"- **{label}**:{text}" for label, _ok, text in lines]
body += [
"",
f"- 结论:**{'就绪' if ready else '未就绪'}**",
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())