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:
1 parent
bff48e27fa
commit
bc1b09b776
6 files changed
+397
-86
No files matched your search
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user