Files
Unlucky-Launcher/packaging/check_signing_ready.py
T
AskaEth bff48e27fa 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`:索引与指针。
2026-11-11 20:05:45 +08:00

201 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())