release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
commit
a5d1dec495
1580 files changed
+320938
No files matched your search
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""GitHub `if:` 表达式的小求值器(M253 / r2 复审返工,判据 3 用)。
|
||||
|
||||
为什么需要它:字符串存在性判据被两轮复审证伪过 —— `if: needs.probe.outputs.available == 'true' || always()`
|
||||
用 `in` 判照样绿,`if: true` 也绿。所以这里**真求值**:给 `needs.probe.outputs.available` 分别取
|
||||
`'true'` / `'false'` / `''`,门必须分别判真 / 假 / 假 —— 门到底还挂不挂,由语义决定,不由子串决定。
|
||||
|
||||
**只认一小撮语法,其余一律抛 `ConditionError`(调用方按红处理,宁可假红)**:
|
||||
`==` / `!=` / `&&` / `||` / 括号 / 单引号字符串 / 数字 / `needs.<job>.outputs.<名>` / `true|false|null` /
|
||||
`always()|success()|failure()|cancelled()`(后四个按「假定上游成功」取常量)。**不支持 `!`**:GitHub 与
|
||||
Python 的 `!` 优先级不同,认不出比误判安全。不是 GitHub 语义的替身,是它的一个保守子集。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
AVAILABLE_IDENT = "needs.probe.outputs.available"
|
||||
CALL_FUNCS = {"always": True, "success": True, "failure": False, "cancelled": False}
|
||||
_TOKEN_RE = re.compile(r"(?P<string>'[^']*')|(?P<number>\d+)|(?P<ident>[A-Za-z_][\w.\-]*)|(?P<op>==|!=|&&|\|\||[()!])")
|
||||
|
||||
|
||||
class ConditionError(Exception):
|
||||
"""`if:` 表达式认不出 —— 认不出即判红,绝不默认放过。"""
|
||||
|
||||
|
||||
def evaluate_condition(raw, available: str) -> bool:
|
||||
"""把 GitHub 的 `if:` 表达式求值。只认一小撮语法,其余抛 `ConditionError`(= 判红)。"""
|
||||
text = str(raw).strip()
|
||||
if text.startswith("${{") and text.endswith("}}"):
|
||||
text = text[3:-2].strip()
|
||||
for name, value in CALL_FUNCS.items():
|
||||
text = re.sub(rf"\b{name}\s*\(\s*\)", "true" if value else "false", text)
|
||||
if re.search(r"\b(" + "|".join(CALL_FUNCS) + r")\s*\(", text):
|
||||
raise ConditionError("函数只支持不带参数的形式")
|
||||
parts: list[str] = []
|
||||
pos = 0
|
||||
while pos < len(text):
|
||||
if text[pos].isspace():
|
||||
pos += 1
|
||||
continue
|
||||
match = _TOKEN_RE.match(text, pos)
|
||||
if not match:
|
||||
raise ConditionError(f"认不出的字符 {text[pos]!r}")
|
||||
pos = match.end()
|
||||
token = match.group()
|
||||
if match.lastgroup in ("string", "number"):
|
||||
parts.append(token)
|
||||
elif match.lastgroup == "ident":
|
||||
if token == AVAILABLE_IDENT:
|
||||
parts.append("__available")
|
||||
elif token in ("true", "false", "null"):
|
||||
parts.append({"true": "True", "false": "False", "null": "None"}[token])
|
||||
else:
|
||||
raise ConditionError(f"认不出的标识符 `{token}`")
|
||||
else:
|
||||
if token == "!":
|
||||
raise ConditionError("不支持 `!`(GitHub 与 Python 的优先级不同,宁可认不出)")
|
||||
parts.append({"&&": "and", "||": "or"}.get(token, token))
|
||||
source = " ".join(parts)
|
||||
try:
|
||||
return bool(eval(source, {"__builtins__": {}}, {"__available": available})) # noqa: S307 - 词表是白名单,见上
|
||||
except Exception as error: # noqa: BLE001 - 任何求值失败都按「认不出」处理
|
||||
raise ConditionError(f"求值失败({error}):`{source}`") from None
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""`gpu-evidence.yml` 的**功能断言**部分(M253 / r1+r2 复审返工):把 workflow 里的 `run:` 原文抽出来真跑。
|
||||
|
||||
为什么单独一份:判据脚本自己守 ≤400 行硬线,而这里全是「把 `run:` 落盘、用桩 env 起 bash、读
|
||||
`$GITHUB_OUTPUT` / `$GITHUB_STEP_SUMMARY` / 退出码」的**执行脚手架**,与「哪条判据该红」的判断无关
|
||||
(同 `gpu_shot_selftest.py` 与 `check_gpu_shot.py` 的分工)。
|
||||
|
||||
两组功能断言,各自对应一类「绿而没判」:
|
||||
|
||||
**A. `detect` 步(`probe_problems`)—— 跳过要留痕、红线不许退化成跳过。**
|
||||
字符串存在性检查被 r1 复审证伪过:旧版只要求「同一步的 run 里同时出现 4 个字符串」,于是把
|
||||
`available` 写成恒 `false`(门禁永久关闭、每次运行都绿)它照样绿 —— **文案都在,执行路径却没走到**。
|
||||
|
||||
| # | 输入 | 必须发生什么 |
|
||||
|---|------|-------------|
|
||||
| S1 | 无产物(CI 常态) | `available=false` + summary 写「跳过 / 未验证」+ stdout 有 `::warning::` |
|
||||
| S2 | 约定产物成对 | `available=true` |
|
||||
| S3 | 约定产物半套(只有日志) | `available=false`(半套不算可用) |
|
||||
| S4 | dispatch 成对 | `available=true` |
|
||||
| S5 | dispatch 点名不存在 | `exit≠0`,且**不写** `available`(写了就等于退化成跳过) |
|
||||
| S6 | dispatch 正例缺 PNG | `exit≠0`,同上 |
|
||||
| S7 | dispatch 负例只给日志 | `available=true`(`--expect-refuse` 允许省略 PNG) |
|
||||
|
||||
**B. `judge` 步(`judge_problems`)—— 判据步必须真的把红判成红。**
|
||||
r2 复审实测:判据命令后面加 `|| echo "先忽略"`、`; :`、去掉 pipefail 再 `| cat`、`if ! python3 …`、
|
||||
`true # python3 …`(保留子串锚点),五种写法的判据步**全 exit 0**(绿而没判),而标记表判据一条都没抓住。
|
||||
所以这里跑两次:正例成对必须 `exit 0`,CPU 档日志(`renderer=tiny-skia`)必须 `exit≠0`。第 2 条一红就
|
||||
同时封掉「吞码」整类,不依赖任何标记表。
|
||||
|
||||
**跑的是原文,只改一个令牌**:`run` 原文里 `python3` 换成本进程的解释器(Windows 上没有 `python3`;
|
||||
控制流、运算符、参数一字不改)。夹具用 `gpu_shot_selftest.synth_log/synth_png`,不另造第二套。
|
||||
判据步的 `cwd` 是仓库根(它要 `packaging/check_gpu_shot.py` 的相对路径)。
|
||||
|
||||
不降级:找不到能跑的 `bash` 就**判红**(「认不出/跑不了」≠「没问题」)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGING = Path(__file__).resolve().parent.parent
|
||||
REPO = PACKAGING.parent
|
||||
sys.path[:0] = [str(PACKAGING), str(Path(__file__).resolve().parent)]
|
||||
from gpu_shot_selftest import SELFTEST_SIZE, synth_log, synth_png # noqa: E402
|
||||
|
||||
_CACHE: dict[str, list[str]] = {}
|
||||
PYTHON_TOKEN = re.compile(r"\bpython3\b")
|
||||
|
||||
|
||||
def _find_bash() -> str | None:
|
||||
"""挑一个**真能跑**的 bash。Windows 上 `bash.exe` 可能是 WSL 的 relay(没有 `/bin/bash`),要验一次。"""
|
||||
candidates = [os.environ.get("BASH"), shutil.which("bash")]
|
||||
if os.name == "nt":
|
||||
candidates += [r"C:\Program Files\Git\bin\bash.exe", r"C:\Program Files\Git\usr\bin\bash.exe"]
|
||||
candidates.append("bash")
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
done = subprocess.run([candidate, "-c", "echo ok"], capture_output=True, text=True, timeout=60)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
if done.returncode == 0 and "ok" in (done.stdout or ""):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _run(bash: str, script: Path, workdir: Path, env_extra: dict, cwd: str | None = None, flags: tuple[str, ...] = ("-e",)):
|
||||
"""跑一份 `run:` 原文,返回 `(CompletedProcess, GITHUB_OUTPUT 字典, GITHUB_STEP_SUMMARY 文本)`。
|
||||
|
||||
`flags` 默认 `-e`(GitHub 的 `bash -e {0}`);判据步还会用**裸 bash**(不带 `-e`)再跑一遍 ——
|
||||
`... ; :` 这种写法只有在不靠 `-e` 兜底时才会把红吞掉(实测:`bash -e` 仍 exit 1,裸 bash exit 0)。
|
||||
"""
|
||||
out_path, sum_path = workdir / "gh_output", workdir / "gh_summary"
|
||||
env = os.environ.copy()
|
||||
env.update({"GITHUB_OUTPUT": out_path.as_posix(), "GITHUB_STEP_SUMMARY": sum_path.as_posix()})
|
||||
env.update({key: ("" if value is None else str(value)) for key, value in env_extra.items()})
|
||||
done = subprocess.run([bash, *flags, str(script)], cwd=cwd or str(workdir), env=env, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
outputs: dict[str, str] = {}
|
||||
if out_path.exists():
|
||||
for line in out_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
key, sep, value = line.partition("=")
|
||||
if sep:
|
||||
outputs[key.strip()] = value
|
||||
summary = sum_path.read_text(encoding="utf-8", errors="replace") if sum_path.exists() else ""
|
||||
return done, outputs, summary
|
||||
|
||||
|
||||
def _case(label: str, done, outputs: dict, summary: str, *, available: str, contains: tuple[str, ...] = (), warning: bool = False) -> list[str]:
|
||||
problems: list[str] = []
|
||||
if done.returncode != 0:
|
||||
problems.append(f"{label}:退出码 {done.returncode},应为 0(stderr: {(done.stderr or '').strip()[:200]})")
|
||||
if outputs.get("available") != available:
|
||||
problems.append(f"{label}:`available={outputs.get('available')!r}`,应为 {available!r} —— 探测结论被改坏了,门就永久开或永久关")
|
||||
for needle in contains:
|
||||
if needle not in summary:
|
||||
problems.append(f"{label}:job summary 里缺 `{needle}` —— 跳过不再留痕")
|
||||
if warning and "::warning::" not in (done.stdout or ""):
|
||||
problems.append(f"{label}:stdout 里没有 `::warning::` —— 跳过变成了静默 pass")
|
||||
return problems
|
||||
|
||||
|
||||
def _probe_scenarios(bash: str, script_text: str) -> list[str]:
|
||||
problems: list[str] = []
|
||||
with tempfile.TemporaryDirectory(prefix="ull-gpu-ci-", ignore_cleanup_errors=True) as tmp:
|
||||
root = Path(tmp)
|
||||
fixtures = root / "fixtures"
|
||||
fixtures.mkdir()
|
||||
log = fixtures / "a1-gpu.stderr.log"
|
||||
log.write_text("[shot] 200x120\n", encoding="utf-8")
|
||||
png = fixtures / "a1-gpu.png"
|
||||
png.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
def case(slug: str, tree: tuple[tuple[str, bytes], ...] = ()):
|
||||
workdir = root / slug
|
||||
workdir.mkdir()
|
||||
for rel, payload in tree:
|
||||
path = workdir / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(payload)
|
||||
script = workdir / "probe.sh"
|
||||
script.write_text(script_text, encoding="utf-8")
|
||||
return script, workdir
|
||||
|
||||
base = {"EVENT": "push", "SHOT_LOG": "", "SHOT_PNG": "", "EXPECT_REFUSE": None}
|
||||
convention = "DevDocs/artifacts/gpu-probe/gpu-shot"
|
||||
script, workdir = case("s1-none")
|
||||
problems += _case("S1 无产物(CI 常态)", *_run(bash, script, workdir, base), available="false", contains=("跳过", "未验证"), warning=True)
|
||||
script, workdir = case("s2-convention-pair", ((f"{convention}.stderr.log", b"x"), (f"{convention}.png", b"x")))
|
||||
problems += _case("S2 约定产物成对", *_run(bash, script, workdir, base), available="true")
|
||||
script, workdir = case("s3-convention-half", ((f"{convention}.stderr.log", b"x"),))
|
||||
problems += _case("S3 约定产物半套(只有日志)", *_run(bash, script, workdir, base), available="false", contains=("跳过",))
|
||||
script, workdir = case("s4-dispatch-pair")
|
||||
problems += _case("S4 dispatch 成对", *_run(bash, script, workdir, dict(base, SHOT_LOG=str(log), SHOT_PNG=str(png))), available="true")
|
||||
for slug, label, extra in (
|
||||
("s5-dispatch-missing", "S5 dispatch 点名不存在", {"SHOT_LOG": str(root / "nope.log"), "SHOT_PNG": str(png)}),
|
||||
("s6-dispatch-no-png", "S6 dispatch 正例缺 PNG", {"SHOT_LOG": str(log), "SHOT_PNG": ""}),
|
||||
):
|
||||
script, workdir = case(slug)
|
||||
done, outputs, _summary = _run(bash, script, workdir, dict(base, **extra))
|
||||
if done.returncode == 0:
|
||||
problems.append(f"{label}:退出码 0 —— 「明确说要判、东西不在」退化成了跳过(假绿)")
|
||||
if outputs:
|
||||
problems.append(f"{label}:该红却写了 `$GITHUB_OUTPUT`({outputs})")
|
||||
script, workdir = case("s7-dispatch-refuse")
|
||||
problems += _case("S7 dispatch 负例只给日志", *_run(bash, script, workdir, dict(base, SHOT_LOG=str(log), SHOT_PNG="", EXPECT_REFUSE="true")), available="true")
|
||||
return problems
|
||||
|
||||
|
||||
def probe_problems(script_text: str) -> list[str]:
|
||||
"""判据 A:`id: detect` 那一步的 `run` 原文实跑 7 种输入(带缓存,同文本只跑一次)。"""
|
||||
if not script_text.strip():
|
||||
return ["`id: detect` 那一步没有 `run:` —— 跳过路径无从执行"]
|
||||
if script_text not in _CACHE:
|
||||
bash = _find_bash()
|
||||
_CACHE[script_text] = (
|
||||
["找不到能跑的 `bash`:跳过路径的功能断言无法执行,本判据不降级(认不出就红)"]
|
||||
if not bash
|
||||
else _probe_scenarios(bash, script_text)
|
||||
)
|
||||
return _CACHE[script_text]
|
||||
|
||||
|
||||
def _judge_scenarios(bash: str, script_text: str) -> list[str]:
|
||||
problems: list[str] = []
|
||||
with tempfile.TemporaryDirectory(prefix="ull-gpu-judge-", ignore_cleanup_errors=True) as tmp:
|
||||
root = Path(tmp)
|
||||
passing = root / "pass.stderr.log"
|
||||
passing.write_text(synth_log(size=SELFTEST_SIZE), encoding="utf-8")
|
||||
cpu = root / "cpu.stderr.log"
|
||||
cpu.write_text(synth_log(renderer="tiny-skia", size=SELFTEST_SIZE), encoding="utf-8")
|
||||
png = root / "pass.png"
|
||||
png.write_bytes(synth_png("content", SELFTEST_SIZE))
|
||||
script = root / "judge.sh"
|
||||
script.write_text(PYTHON_TOKEN.sub(lambda _match: f'"{Path(sys.executable).as_posix()}"', script_text), encoding="utf-8")
|
||||
common = {"PNG": png.as_posix(), "EXPECT_REFUSE": "false"}
|
||||
# 两种模式都跑:`-e` = GitHub 的默认 shell;裸 bash = 不靠 `-e` 兜底(`; :` 只在后者现形)。
|
||||
for mode, flags in (("bash -e", ("-e",)), ("裸 bash", ())):
|
||||
for label, log_path, want_zero in (
|
||||
("正例(六条判据全过)", passing, True),
|
||||
("CPU 档日志(`renderer=tiny-skia`,必须判红)", cpu, False),
|
||||
):
|
||||
done, _outputs, _summary = _run(bash, script, root, dict(common, LOG=log_path.as_posix()), cwd=str(REPO), flags=flags)
|
||||
if want_zero and done.returncode != 0:
|
||||
problems.append(
|
||||
f"[{mode}] 判据步对**正例**退出码 {done.returncode},应为 0 —— 「判据全过」这条路走不通了"
|
||||
f"(stderr: {(done.stderr or '').strip()[:200]})"
|
||||
)
|
||||
if not want_zero and done.returncode == 0:
|
||||
problems.append(
|
||||
f"[{mode}] 判据步对**必须判红**的输入退出码 0 —— 「绿而没判」:`|| echo` / `; :` / 管道 / `if !` / "
|
||||
"`true # 原行` / 裸 `exit 0` 都属于这一类"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def judge_problems(script_text: str) -> list[str]:
|
||||
"""判据 B:`id: judge` 那一步的 `run` 原文实跑两次(正例必须 0、CPU 档必须非 0)。"""
|
||||
if not script_text.strip():
|
||||
return ["`id: judge` 那一步没有 `run:` —— 判据步无从执行"]
|
||||
key = "judge:" + script_text
|
||||
if key not in _CACHE:
|
||||
bash = _find_bash()
|
||||
_CACHE[key] = (
|
||||
["找不到能跑的 `bash`:判据步的功能断言无法执行,本判据不降级(认不出就红)"]
|
||||
if not bash
|
||||
else _judge_scenarios(bash, script_text)
|
||||
)
|
||||
return _CACHE[key]
|
||||
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""`gpu-evidence.yml` 的**接线自检**(M253 / r1+r2 复审返工):把「这道门禁怎么才算没被改绿」变成判据。
|
||||
|
||||
历史(留着,防止下一个人再写绝对化结论):
|
||||
- r1 版宣称「不可能被改成总是绿」,复审造 19 条变异**漏 11 条**(最要命:`available` 恒 false ⇒ 门永久
|
||||
关闭而每次绿)—— 根因是判据只做字符串存在性检查、自测步被整条豁免、`is True` 挡不住 `'true'`。
|
||||
- r2 版改成 6 条判据后,复审 40 条新变异**仍漏 11 条**:job outputs 的 `available` 绑到 `reason`/字面量
|
||||
(门永久关、每次绿)、判据步 `|| echo "先忽略"` / `; :` / `| cat` / `if !` / `true # 原行`(**绿而没判**,
|
||||
真 bash 实测 exit 0)、自检步被指向另一个脚本(自检根本不跑)、`on:` 触发面被删。
|
||||
教训:**「不吞退出码」不能靠标记表**,得把 `run` 原文真跑一遍。
|
||||
|
||||
现在十条判据,能结构化的不靠字符串、能跑的不靠读文本:
|
||||
|
||||
| # | 判据 | 怎么判 |
|
||||
|---|------|--------|
|
||||
| 1 | 五步必须在场 | 按 `id:` 认 `vectors`/`exitcodes`/`wiring`/`detect`/`judge`,各恰好一步;前四步不许有 `if:`(step 或 job 级);`exitcodes` 必须真断言 `expect 0/1/2` |
|
||||
| 2 | 自测步跑的是对脚本、且是单条命令 | `wiring` 的 `run` 必须含 `gpu_shot_ci_selftest.py`;`vectors`/`wiring` 的 `run` 不许有换行、`\|`、`&`、`;`、反引号、`#`、`$(` —— 否则红了之后一行 `\|\| echo 先忽略` 就能关掉它 |
|
||||
| 3 | 判定必须挂门 | **解析 `if:` 表达式后求值**:`available` 取 `'true'` 必须真、`'false'`/`''` 必须假;job 级与 step 级各自都要满足;认不出的表达式一律红 |
|
||||
| 4 | 判定用探测给的产物 | `run` 里两种调用形态都在场(`"$LOG" "$PNG"` 与 `"$LOG" --expect-refuse`),`env` 来自 `needs.probe.outputs.*` |
|
||||
| 5 | 判据步**真跑两次** | `judge` 的 `run` 原文实跑:正例成对必须 `exit 0`,CPU 档日志(`renderer=tiny-skia`)必须 `exit≠0` —— 一次封掉整类「吞码」 |
|
||||
| 6 | 跳过路径**真跑 7 种输入** | `detect` 的 `run` 原文实跑:`available` 的值、summary 文案、`::warning::`、三条红线必须 `exit≠0` 且不写 outputs |
|
||||
| 7 | 禁 `continue-on-error` | 按 YAML 布尔语义:`true` / `'true'` / `${{ true }}` / 任意表达式都算开启 |
|
||||
| 8 | 不吞退出码(标记表) | 扫所有 `python`+`packaging/` 的 `run`:`\|\| true` / `; true` / `\|\| exit 0` / 裸 `exit 0`(判据 5 的功能断言才是主守,这张表只是兜底) |
|
||||
| 9 | `probe.outputs` 必须绑到 `detect` | `available`/`log`/`png` 三个键的值必须**逐个**等于 `${{ steps.detect.outputs.<同名> }}` |
|
||||
| 10 | 触发面 | `on:` 必须同时有 `push`(含 `main`)、`pull_request`、`workflow_call` |
|
||||
|
||||
## 覆盖的「改绿」类别(封闭清单:10 类,33 条变异向量)
|
||||
|
||||
变异数据在 `packaging/tests/gpu_shot_ci_vectors.json`,**按解析后的结构注入**(`step_delete` /
|
||||
`job_delete` / `step_set` / `job_set` / `path_set` / `trigger_set` / `run_append` / `run_replace`,
|
||||
`also` 可叠加),每条都必须让对应判据当场红、基线必须全绿。类别与来源:
|
||||
|
||||
1. 步骤被删 / 被 `if:` 挡住 / 被 `continue-on-error` 兜住(r1 M7/M9/M10/M13/M15/M16/M17/M18、r2 N15/N18);
|
||||
2. 自测步指向别的脚本、或写成能被 `|| echo` 关掉的多行/带运算符命令(r2 N40/N41/N23);
|
||||
3. 判定的门被写成恒真(`|| always()`、`if: true`、step 级 `if: ${{ always() }}`、删门)(r1 M3/M19、r2 N4/N5);
|
||||
4. 判定的产物路径被写死或调用形态被换掉(r1 M20、r2 N9);
|
||||
5. 判据步的退出码被吞 / 分支没走到(r1 M4、r2 N22/N24/N25/N26/N8'—— 都用真 bash 实跑判据步验证);
|
||||
6. `detect` 的结论被改坏(`available` 恒 false、跳过不留痕、删 `$GITHUB_OUTPUT`)(r1 M11/M12/M5/M6/M14、r2 N6/N7/N20);
|
||||
7. `probe.outputs.*` 被改指向别的 step 输出或字面量(**r2 N28/N29**);
|
||||
8. `on:` 触发面被削(删 `push`/`pull_request`/`workflow_call`)(**r2 N11/N12**);
|
||||
9. 排版变动**不误红**(加注释/空行基线仍绿)、变异锚点坏掉**报「向量失效」并红**(r2 N16/N17);
|
||||
10. `if:` 求值器不接受近似写法(`!=`、`!(…)`、`fromJSON`、未知标识符一律红)(r2 N1/N2/N3)。
|
||||
|
||||
## 明确**不覆盖**(边界,逐条给理由)
|
||||
|
||||
- **改判据脚本本身**:能改这个文件的人也能改判据,它不自守。它只防「顺手改绿」,不防恶意改绿。
|
||||
- **载体依赖**:把整个 `gpu-evidence.yml` 删掉/换成别的文件,就没有任何东西会跑这个自检 —— 守门人住在
|
||||
被守对象自己的文件里。`guards.yml` 已经 399/400 行(`file-size` 那道门就扫 `*.yml`),塞不进去。
|
||||
- **`if:` 求值器与 GitHub 语义的差异**:只认 `==`/`!=`/`&&`/`||`/括号/字符串/`needs.<job>.outputs.<名>`/
|
||||
`true|false|null`/`always()|success()|failure()|cancelled()`(后四者取常量);其余认不出就是红。
|
||||
- **判据 4 的「合法调用替换」**:把正例分支换成另一种**合法**调用(`"$PNG" --expect-refuse`)抓不到;
|
||||
后果是假红不是假绿,判据 5 也会把它判红(正例跑成非 0)。
|
||||
- **`exitcodes` 那一步的断言被削弱**(例如末尾加 `|| echo`):它是**见证步**,不是门;门的绿由判据 5/6
|
||||
的功能断言守住,见证步被削只影响「退出码语义也被验过」这条证据。
|
||||
- **判据 6 的 7 种输入之外的分支**(例如 `detect` 里新增的第五个分支)。
|
||||
- **真 Actions 的行为**:`if:` 的真求值、job skip 的呈现、required status check 与 skipped 的交互,
|
||||
本脚本一概不管(`if:` 语义是本脚本的小求值器模拟的)。
|
||||
|
||||
## 自我定位与止步线
|
||||
|
||||
本文件**不宣称**「不可能被改成总是绿」,只宣称「上面那张封闭清单(10 类 / 33 条向量)会被抓」。
|
||||
**这条门禁自 2026-11-11 起停止加固**:再往下就会无限递归(守卫守守卫守守卫),边际收益已很低。
|
||||
后续若要扩覆盖面,必须由**人类新开 mission**,不要在同一轮里继续加判据。
|
||||
|
||||
python3 packaging/tests/gpu_shot_ci_selftest.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGING = Path(__file__).resolve().parent.parent
|
||||
WORKFLOW = PACKAGING.parent / ".github/workflows/gpu-evidence.yml"
|
||||
VECTORS_PATH = Path(__file__).resolve().parent / "gpu_shot_ci_vectors.json"
|
||||
sys.path.insert(0, str(PACKAGING))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from check_workflows_yaml import load_yaml # noqa: E402
|
||||
from gpu_shot_ci_expr import ConditionError, evaluate_condition # noqa: E402
|
||||
from gpu_shot_ci_probe import judge_problems, probe_problems # noqa: E402
|
||||
|
||||
#: 必须在场、且必须无条件的步骤(按 `id:` 认,不按步骤名认)。
|
||||
REQUIRED_STEPS = {
|
||||
"vectors": "判据实现自测(`--self-test`,19 条向量)",
|
||||
"exitcodes": "退出码语义(真 CLI 走 0/1/2)",
|
||||
"wiring": "接线自检(本脚本,必须在场)",
|
||||
"detect": "产物探测(probe 的唯一步骤)",
|
||||
"judge": "真实产物判据(evidence 的步骤)",
|
||||
}
|
||||
UNCONDITIONAL = ("vectors", "exitcodes", "wiring", "detect")
|
||||
PROBE_OUTPUT_KEYS = ("available", "log", "png")
|
||||
SINGLE_COMMAND_STEPS = ("vectors", "wiring")
|
||||
SHELL_OPERATOR = re.compile(r"[|&;`#$()<>\n]")
|
||||
SWALLOW_MARKS = ("|| true", "; true", "|| exit 0", "exit 0")
|
||||
PYTHON_RE = re.compile(r"\bpython3?\b")
|
||||
|
||||
|
||||
def coerce_flag(value) -> bool:
|
||||
"""YAML 里的开关按**真语义**判:`true` / `'true'` / `${{ true }}` 都算开启。"""
|
||||
if value is True:
|
||||
return True
|
||||
if value is False or value is None:
|
||||
return False
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
return str(value).strip().lower() not in {"false", "${{ false }}", "0", "no", "off", ""}
|
||||
|
||||
|
||||
def steps(doc: dict) -> list[tuple[str, dict, dict]]:
|
||||
found: list[tuple[str, dict, dict]] = []
|
||||
for job_id, job in (doc.get("jobs") or {}).items():
|
||||
for step in (job or {}).get("steps") or []:
|
||||
if isinstance(step, dict):
|
||||
found.append((job_id, job, step))
|
||||
return found
|
||||
|
||||
|
||||
def step_by_id(doc: dict, sid: str) -> tuple[str, dict, dict] | None:
|
||||
found = [entry for entry in steps(doc) if entry[2].get("id") == sid]
|
||||
return found[0] if len(found) == 1 else None
|
||||
|
||||
|
||||
def run_of(doc: dict, sid: str) -> str:
|
||||
entry = step_by_id(doc, sid)
|
||||
return (entry[2].get("run") or "") if entry else ""
|
||||
|
||||
|
||||
def check_presence(doc: dict) -> list[str]:
|
||||
problems: list[str] = []
|
||||
counts = {sid: len([e for e in steps(doc) if e[2].get("id") == sid]) for sid in REQUIRED_STEPS}
|
||||
for sid, label in REQUIRED_STEPS.items():
|
||||
if counts[sid] != 1:
|
||||
problems.append(f"`id: {sid}`({label})必须恰好一步,实际 {counts[sid]} 步 —— 删掉或复制都算红")
|
||||
continue
|
||||
job_id, job, step = step_by_id(doc, sid)
|
||||
if sid in UNCONDITIONAL and ("if" in step or "if" in job):
|
||||
problems.append(f"`id: {sid}`({label})带 `if:`(步骤或 job 级)—— 这四步必须无条件跑,否则门禁可被静默关掉")
|
||||
asserted = set(re.findall(r"\bexpect\s+([0-9]+)\b", run_of(doc, "exitcodes")))
|
||||
missing = {"0", "1", "2"} - asserted
|
||||
if step_by_id(doc, "exitcodes") and missing:
|
||||
problems.append(f"`id: exitcodes` 没有断言退出码 {'/'.join(sorted(missing))}(`expect <码>`)—— 三个码就是门禁的接口")
|
||||
return problems
|
||||
|
||||
|
||||
def check_selftest_commands(doc: dict) -> list[str]:
|
||||
problems: list[str] = []
|
||||
wiring = run_of(doc, "wiring").strip()
|
||||
if wiring and "gpu_shot_ci_selftest.py" not in wiring:
|
||||
problems.append("`id: wiring` 的 `run` 里没有 `gpu_shot_ci_selftest.py` —— 它可能指向别的脚本(自检根本没跑)")
|
||||
for sid in SINGLE_COMMAND_STEPS:
|
||||
run = run_of(doc, sid).strip()
|
||||
if not run:
|
||||
continue
|
||||
if SHELL_OPERATOR.search(run):
|
||||
problems.append(
|
||||
f"`id: {sid}` 的 `run` 必须是**单条命令**(不许换行 / `|` / `&` / `;` / 反引号 / `#` / `$(`)"
|
||||
f"—— 否则红了之后一行 `|| echo 先忽略` 就能把它关掉:`{run}`"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def check_judge_gate(doc: dict) -> list[str]:
|
||||
entry = step_by_id(doc, "judge")
|
||||
if entry is None:
|
||||
return [] # presence 已经报过
|
||||
job_id, job, step = entry
|
||||
gates: list[tuple[str, object]] = []
|
||||
if "if" in job:
|
||||
gates.append((f"job `{job_id}` 的 `if`", job.get("if")))
|
||||
if "if" in step:
|
||||
gates.append(("`id: judge` 步的 `if`", step.get("if")))
|
||||
if not gates:
|
||||
return ["`judge` 步与它的 job 都没有 `if:` —— 无产物时也会照跑,「跳过」与「通过」分不开"]
|
||||
problems: list[str] = []
|
||||
for label, raw in gates:
|
||||
for available, want in (("true", True), ("false", False), ("", False)):
|
||||
try:
|
||||
got = evaluate_condition(raw, available)
|
||||
except ConditionError as error:
|
||||
problems.append(f"{label} 认不出({error})—— 认不出即红:`{raw}`")
|
||||
break
|
||||
if got is not want:
|
||||
problems.append(f"{label} 在 `available={available!r}` 时求值为 {got},应为 {want}:`{raw}`")
|
||||
return problems
|
||||
|
||||
|
||||
def check_judge_invocation(doc: dict) -> list[str]:
|
||||
entry = step_by_id(doc, "judge")
|
||||
if entry is None:
|
||||
return []
|
||||
run = entry[2].get("run") or ""
|
||||
env = entry[2].get("env") or {}
|
||||
problems: list[str] = []
|
||||
for form in ('check_gpu_shot.py "$LOG" "$PNG"', 'check_gpu_shot.py "$LOG" --expect-refuse'):
|
||||
if form not in run:
|
||||
problems.append(f"`id: judge` 的 `run` 里缺调用形态 `{form}` —— 该分支可以「什么都没判」就绿")
|
||||
for name, ref in (("LOG", "needs.probe.outputs.log"), ("PNG", "needs.probe.outputs.png")):
|
||||
if ref not in str(env.get(name, "")):
|
||||
problems.append(f"`judge` 的 `{name}` 不是来自 `{ref}`(写死的路径会在跳过那条路上判错东西)")
|
||||
return problems
|
||||
|
||||
|
||||
def check_probe_outputs(doc: dict) -> list[str]:
|
||||
outputs = (doc.get("jobs") or {}).get("probe", {}).get("outputs") or {}
|
||||
problems: list[str] = []
|
||||
for key in PROBE_OUTPUT_KEYS:
|
||||
want = "${{ steps.detect.outputs.%s }}" % key
|
||||
got = str(outputs.get(key, "")).strip()
|
||||
if got != want:
|
||||
problems.append(
|
||||
f"`probe.outputs.{key}` 必须恰好是 `{want}`,实际 {got!r} —— "
|
||||
"改指向(`reason` / 字面量 `'false'`)会让门永久关掉,而每次运行都绿"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def check_triggers(doc: dict) -> list[str]:
|
||||
on_block = doc.get("on", doc.get(True))
|
||||
if not isinstance(on_block, dict):
|
||||
return ["`on:` 不是一个映射 —— 触发面认不出(认不出即红)"]
|
||||
problems: list[str] = []
|
||||
if "push" not in on_block:
|
||||
problems.append("`on:` 少了 `push` —— main 上的门禁会停摆")
|
||||
else:
|
||||
branches = (on_block.get("push") or {}).get("branches") if isinstance(on_block.get("push"), dict) else None
|
||||
if branches is not None and "main" not in [str(branch) for branch in branches]:
|
||||
problems.append("`on.push.branches` 里没有 `main` —— main 上的门禁会停摆")
|
||||
if "pull_request" not in on_block:
|
||||
problems.append("`on:` 少了 `pull_request` —— PR 上的门禁会停摆")
|
||||
if "workflow_call" not in on_block:
|
||||
problems.append("`on:` 少了 `workflow_call` —— `dev-guards.yml` 的 `uses:` 会加载失败")
|
||||
return problems
|
||||
|
||||
|
||||
def check_continue_on_error(doc: dict) -> list[str]:
|
||||
problems: list[str] = []
|
||||
for job_id, job in (doc.get("jobs") or {}).items():
|
||||
if coerce_flag((job or {}).get("continue-on-error")):
|
||||
problems.append(f"job `{job_id}` 带 `continue-on-error: {job.get('continue-on-error')!r}` —— 判据红也会变绿")
|
||||
for step in (job or {}).get("steps") or []:
|
||||
if isinstance(step, dict) and coerce_flag(step.get("continue-on-error")):
|
||||
problems.append(f"job `{job_id}` 的 `{step.get('id') or step.get('name')}` 带 `continue-on-error: {step.get('continue-on-error')!r}`")
|
||||
return problems
|
||||
|
||||
|
||||
def check_no_swallow(doc: dict) -> list[str]:
|
||||
problems: list[str] = []
|
||||
for job_id, _job, step in steps(doc):
|
||||
run = step.get("run") or ""
|
||||
if not (PYTHON_RE.search(run) and "packaging/" in run):
|
||||
continue
|
||||
hits = [mark for mark in SWALLOW_MARKS if mark in run]
|
||||
if hits:
|
||||
problems.append(f"job `{job_id}` 的 `{step.get('id') or step.get('name')}` 里出现 {'/'.join(f'`{h}`' for h in hits)} —— 退出码被吞掉,判据红也绿")
|
||||
return problems
|
||||
|
||||
|
||||
CHECKS = {
|
||||
"1 五步必须在场": check_presence,
|
||||
"2 自测步跑的是对脚本且是单条命令": check_selftest_commands,
|
||||
"3 判定必须挂门": check_judge_gate,
|
||||
"4 判定用探测给的产物": check_judge_invocation,
|
||||
"5 判据步真跑(正例 0 / CPU 档非 0)": lambda doc: judge_problems(run_of(doc, "judge")),
|
||||
"6 跳过路径真跑(7 种输入)": lambda doc: probe_problems(run_of(doc, "detect")),
|
||||
"7 禁 continue-on-error": check_continue_on_error,
|
||||
"8 不吞退出码(标记表,兜底)": check_no_swallow,
|
||||
"9 probe.outputs 绑定 detect": check_probe_outputs,
|
||||
"10 触发面(push/pull_request/workflow_call)": check_triggers,
|
||||
}
|
||||
|
||||
|
||||
def evaluate(doc: dict) -> dict[str, list[str]]:
|
||||
if not isinstance(doc, dict) or not isinstance(doc.get("jobs"), dict):
|
||||
return {name: ["workflow 不是一个带 jobs 的 YAML 映射"] for name in CHECKS}
|
||||
return {name: check(doc) for name, check in CHECKS.items()}
|
||||
|
||||
|
||||
def _walk(doc: dict, path: list) -> tuple[dict, object]:
|
||||
node = doc
|
||||
for key in path[:-1]:
|
||||
node = node[key]
|
||||
return node, path[-1]
|
||||
|
||||
|
||||
def apply_vector(doc: dict, vector: dict) -> dict:
|
||||
"""按**解析后的结构**注入变异(不碰文本格式,改动 workflow 的排版不会让向量过期)。"""
|
||||
op = vector["op"]
|
||||
target = str(vector.get("target", ""))
|
||||
on_key = "on" if "on" in doc else True
|
||||
if op in ("step_delete", "step_set", "run_append", "run_replace"):
|
||||
entry = step_by_id(doc, target)
|
||||
if entry is None:
|
||||
raise KeyError(f"找不到唯一的 `id: {target}`")
|
||||
step = entry[2]
|
||||
if op == "step_delete":
|
||||
entry[1]["steps"].remove(step)
|
||||
elif op == "step_set":
|
||||
step[vector["key"]] = vector["value"]
|
||||
else:
|
||||
text = step.get("run") or ""
|
||||
if op == "run_append":
|
||||
step["run"] = text + vector["value"]
|
||||
else:
|
||||
find, replacement, occurrence = vector["find"], vector["replace"], int(vector.get("occurrence", 1))
|
||||
pos = -1
|
||||
for _ in range(occurrence):
|
||||
pos = text.find(find, pos + 1)
|
||||
if pos < 0:
|
||||
raise KeyError(f"`id: {target}` 的 run 里找不到第 {occurrence} 处 `{find}`")
|
||||
step["run"] = text[:pos] + replacement + text[pos + len(find) :]
|
||||
elif op in ("job_delete", "job_set"):
|
||||
if target not in (doc.get("jobs") or {}):
|
||||
raise KeyError(f"没有 job `{target}`")
|
||||
if op == "job_delete":
|
||||
del doc["jobs"][target]
|
||||
else:
|
||||
doc["jobs"][target][vector["key"]] = vector["value"]
|
||||
elif op == "path_set":
|
||||
parent, key = _walk(doc, vector["path"])
|
||||
if key not in parent:
|
||||
raise KeyError(f"路径不存在:{vector['path']}")
|
||||
parent[key] = vector["value"]
|
||||
elif op == "trigger_set":
|
||||
doc[on_key] = vector["value"]
|
||||
else:
|
||||
raise KeyError(f"不认识的变异操作 `{op}`")
|
||||
for extra in vector.get("also", []):
|
||||
apply_vector(doc, extra)
|
||||
return doc
|
||||
|
||||
|
||||
def main() -> int:
|
||||
yaml = load_yaml()
|
||||
if not WORKFLOW.is_file():
|
||||
print(f"::error file={WORKFLOW}::接线自检的靶子不存在", file=sys.stderr)
|
||||
return 1
|
||||
doc = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||
failures = 0
|
||||
for name, problems in evaluate(doc).items():
|
||||
if problems:
|
||||
failures += 1
|
||||
print(f"FAIL: 判据「{name}」没过:", file=sys.stderr)
|
||||
for problem in problems:
|
||||
print(f" - {problem}", file=sys.stderr)
|
||||
else:
|
||||
print(f"OK: 判据「{name}」")
|
||||
|
||||
vectors = json.loads(VECTORS_PATH.read_text(encoding="utf-8"))["vectors"]
|
||||
for vector in vectors:
|
||||
label = f"{vector['id']} {vector['label']}"
|
||||
try:
|
||||
mutated = apply_vector(copy.deepcopy(doc), vector)
|
||||
except Exception as error: # noqa: BLE001 - 向量失效(锚点没了)同样算红,逼着人更新它
|
||||
failures += 1
|
||||
print(f"FAIL: 变异「{label}」注入失败(向量失效):{error}", file=sys.stderr)
|
||||
continue
|
||||
broken = [name for name, problems in evaluate(mutated).items() if problems]
|
||||
if vector["expect"] not in broken:
|
||||
failures += 1
|
||||
print(f"FAIL: 变异「{label}」没被抓到(应红「{vector['expect']}」,实际红的是 {broken or '无'})", file=sys.stderr)
|
||||
else:
|
||||
print(f"OK: 变异「{label}」→ 「{vector['expect']}」当场红")
|
||||
|
||||
if failures:
|
||||
print(f"FAIL:{failures} 处没过({WORKFLOW.name})", file=sys.stderr)
|
||||
return 1
|
||||
print(f"ok: {len(CHECKS)} 条接线判据全过,{len(vectors)} 条变异向量全部被抓({WORKFLOW.name})")
|
||||
print(" 封闭清单 10 类(见 docstring);不覆盖:改判据脚本 / 删掉整个 workflow(载体依赖)/ 求值器语义差 / 合法调用替换。")
|
||||
print(" 本门禁自 2026-11-11 起停止加固,扩项须由人类新开 mission。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"_note": "M253 接线自检的变异向量。全部按解析后的结构注入(排版变动不影响):op ∈ step_delete / job_delete / step_set / job_set / path_set / trigger_set / run_append / run_replace;`also` 可叠加一组后续注入。expect = 必须报错的判据名(与 gpu_shot_ci_selftest.py 的 CHECKS 键逐字一致)。计数:33 = r1 复审表 20 条 + 1 条补充(M20)+ r2 复审 12 条(N28/N29/N22/N23/N24/N25/N26/N8'/N40/N41/N11/N12)。",
|
||||
"vectors": [
|
||||
{"id": "M1", "label": "自测步命令后加 || true(自测被吞)", "op": "run_append", "target": "vectors", "value": " || true", "expect": "8 不吞退出码(标记表,兜底)"},
|
||||
{"id": "M2", "label": "接线自检步命令后加 || true(守卫守不到自己)", "op": "run_append", "target": "wiring", "value": " || true", "expect": "8 不吞退出码(标记表,兜底)"},
|
||||
{"id": "M2b", "label": "接线自检步命令后加 ; true", "op": "run_append", "target": "wiring", "value": "; true", "expect": "2 自测步跑的是对脚本且是单条命令"},
|
||||
{"id": "M3", "label": "判定的门加 || always()(门形同虚设)", "op": "job_set", "target": "evidence", "key": "if", "value": "needs.probe.outputs.available == 'true' || always()", "expect": "3 判定必须挂门"},
|
||||
{"id": "M4", "label": "判据命令后加裸 exit 0", "op": "run_append", "target": "judge", "value": "\nexit 0\n", "expect": "8 不吞退出码(标记表,兜底)"},
|
||||
{"id": "M5", "label": "整行删掉 ::warning:: 告警", "op": "run_replace", "target": "detect", "find": "echo \"::warning::GPU 证据:跳过(${reason})—— 本次运行没有判过任何真实产物,不构成「GPU 证据已验证」\"", "replace": "", "expect": "6 跳过路径真跑(7 种输入)"},
|
||||
{"id": "M6", "label": "不再写 $GITHUB_OUTPUT(判定永远拿不到产物)", "op": "run_replace", "target": "detect", "find": ">> \"$GITHUB_OUTPUT\"", "replace": ">> /dev/null", "expect": "6 跳过路径真跑(7 种输入)"},
|
||||
{"id": "M7", "label": "selftest job 加 job 级 if: false", "op": "job_set", "target": "selftest", "key": "if", "value": false, "expect": "1 五步必须在场"},
|
||||
{"id": "M8", "label": "探测步加 continue-on-error: true", "op": "step_set", "target": "detect", "key": "continue-on-error", "value": true, "expect": "7 禁 continue-on-error"},
|
||||
{"id": "M9", "label": "接线自检步加 if: false", "op": "step_set", "target": "wiring", "key": "if", "value": false, "expect": "1 五步必须在场"},
|
||||
{"id": "M10", "label": "整步删掉接线自检", "op": "step_delete", "target": "wiring", "expect": "1 五步必须在场"},
|
||||
{"id": "M11", "label": "dispatch 分支 available 恒 false(门禁永久关闭)", "op": "run_replace", "target": "detect", "find": "available=true", "replace": "available=false", "occurrence": 1, "expect": "6 跳过路径真跑(7 种输入)"},
|
||||
{"id": "M12", "label": "约定产物分支 available 恒 false(门禁永久关闭)", "op": "run_replace", "target": "detect", "find": "available=true", "replace": "available=false", "occurrence": 2, "expect": "6 跳过路径真跑(7 种输入)"},
|
||||
{"id": "M13", "label": "整块删掉 evidence job", "op": "job_delete", "target": "evidence", "expect": "1 五步必须在场"},
|
||||
{"id": "M14", "label": "::warning:: 改成裸 echo(跳过变静默 pass)", "op": "run_replace", "target": "detect", "find": "::warning::", "replace": "", "expect": "6 跳过路径真跑(7 种输入)"},
|
||||
{"id": "M15", "label": "整步删掉 --self-test", "op": "step_delete", "target": "vectors", "expect": "1 五步必须在场"},
|
||||
{"id": "M16", "label": "整步删掉退出码语义 0/1/2", "op": "step_delete", "target": "exitcodes", "expect": "1 五步必须在场"},
|
||||
{"id": "M17", "label": "continue-on-error: 'true'(带引号)", "op": "step_set", "target": "detect", "key": "continue-on-error", "value": "true", "expect": "7 禁 continue-on-error"},
|
||||
{"id": "M18", "label": "continue-on-error: ${{ true }}(表达式)", "op": "step_set", "target": "detect", "key": "continue-on-error", "value": "${{ true }}", "expect": "7 禁 continue-on-error"},
|
||||
{"id": "M19", "label": "判定步加 if: ${{ always() }}", "op": "step_set", "target": "judge", "key": "if", "value": "${{ always() }}", "expect": "3 判定必须挂门"},
|
||||
{"id": "M20", "label": "判定步的正例调用换成 true(不再调判据脚本)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "true", "expect": "4 判定用探测给的产物"},
|
||||
{"id": "N28", "label": "probe.outputs.available 改指向 reason(门永久关、每次绿)", "op": "path_set", "path": ["jobs", "probe", "outputs", "available"], "value": "${{ steps.detect.outputs.reason }}", "expect": "9 probe.outputs 绑定 detect"},
|
||||
{"id": "N29", "label": "probe.outputs.available 改成字面量 'false'(门永久关)", "op": "path_set", "path": ["jobs", "probe", "outputs", "available"], "value": "false", "expect": "9 probe.outputs 绑定 detect"},
|
||||
{"id": "N22", "label": "判据命令后加 || echo 先忽略(绿而没判)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\" || echo \"判据没过(先忽略)\"", "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
|
||||
{"id": "N24", "label": "判据命令后加 ; :(吞退出码)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\" ; :", "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
|
||||
{"id": "N25", "label": "去掉 pipefail 再把判据命令接管道 | cat(吞退出码)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\" | cat", "also": [{"op": "run_replace", "target": "judge", "find": "set -uo pipefail", "replace": "set -u"}], "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
|
||||
{"id": "N26", "label": "判据命令包进 if ! … then echo; fi(吞退出码)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "if ! python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"; then echo \"没判过\"; fi", "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
|
||||
{"id": "N8b", "label": "判据命令改成 true 后注释掉原行(保留子串锚点)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "true # python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
|
||||
{"id": "N40", "label": "接线自检步指向 gpu_shot_ci_probe.py(自检根本不跑)", "op": "step_set", "target": "wiring", "key": "run", "value": "python3 packaging/tests/gpu_shot_ci_probe.py", "expect": "2 自测步跑的是对脚本且是单条命令"},
|
||||
{"id": "N41", "label": "接线自检步指向另一个自测(自检根本不跑)", "op": "step_set", "target": "wiring", "key": "run", "value": "python3 packaging/check_gpu_shot.py --self-test", "expect": "2 自测步跑的是对脚本且是单条命令"},
|
||||
{"id": "N23", "label": "接线自检步后加 || echo 先忽略(守卫把自己关了)", "op": "run_append", "target": "wiring", "value": " || echo \"自检没过(先忽略)\"", "expect": "2 自测步跑的是对脚本且是单条命令"},
|
||||
{"id": "N11", "label": "删掉 push / pull_request 触发(main、PR 侧停摆)", "op": "trigger_set", "value": ["workflow_dispatch"], "expect": "10 触发面(push/pull_request/workflow_call)"},
|
||||
{"id": "N12", "label": "删掉 workflow_call 触发(dev 线 uses 会加载失败)", "op": "trigger_set", "value": {"push": {"branches": ["main"]}, "pull_request": null, "workflow_dispatch": null}, "expect": "10 触发面(push/pull_request/workflow_call)"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""`packaging/check_gpu_shot.py --self-test` 的执行器(M249):合成日志 + 合成 PNG + 向量比对。
|
||||
|
||||
为什么单独一份而不是塞进判据脚本:那一份要守 ≤400 行硬线,而这里全是「按真实行格式手拼日志、
|
||||
现造 PNG、逐条对 `expect_fail`」的**测试脚手架**,与判据逻辑无关。数据另外放
|
||||
`gpu_shot_vectors.json`(同 `guard_vectors.json` 的做法),被测的判据函数由调用方**以参数传入**
|
||||
——本文件不反向 import `check_gpu_shot`,免得循环导入。
|
||||
|
||||
一颗诚实的钉子:本文件的样本是**按今天的日志格式**手拼的,所以它守**判据**,不守**格式漂移**。
|
||||
产品真换了 Debug 形状时,靠的是判据里「取不到 device_type 就算失败」这类保守口径(见 doc §3)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # `check_screenshot` 在 packaging/
|
||||
from check_screenshot import encode_png # noqa: E402
|
||||
|
||||
#: 向量数据。`log` 是 `synth_log` 的关键字覆盖(缺省键取函数默认值,`null` = 该行不出现)。
|
||||
VECTORS_PATH = Path(__file__).resolve().parent / "gpu_shot_vectors.json"
|
||||
|
||||
SELFTEST_SIZE = (200, 120)
|
||||
|
||||
|
||||
def synth_log(
|
||||
*,
|
||||
renderer: str | None = "wgpu",
|
||||
device_type: str | None = "DiscreteGpu",
|
||||
backend: str | None = "final=wgpu result=started",
|
||||
size: tuple[int, int] | None = None,
|
||||
no_size_line: bool = False,
|
||||
multiline_selected: bool = False,
|
||||
empty_selected: bool = False,
|
||||
extra_shot_renderer: str | None = None,
|
||||
) -> str:
|
||||
"""按 M246 真实日志的**行格式**拼一份最小日志(传 `None` = 该行故意不出现)。"""
|
||||
lines = [
|
||||
"[probe] renderer = wgpu reason=forced-gpu 兜底=tiny-skia(失败会先打印具名错误)",
|
||||
# 真实日志里永远有这一行提示文本,且它含 `Selected: AdapterInfo` 字样 —— 留着它,
|
||||
# 「只有提示、没有真 Selected 行」的向量才是在测真东西。
|
||||
"[probe] 取证:出现 `[gpu] iced_wgpu: …Selected: AdapterInfo…` ⇒ GPU 真生效;出现 `[gpu] wgpu 初始化失败` ⇒ 已降级 CPU",
|
||||
]
|
||||
if empty_selected: # `Selected: AdapterInfo {` 之后再没有内容(字段全丢)
|
||||
lines.append("[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo {")
|
||||
elif device_type is not None:
|
||||
fields = [
|
||||
' name: "AMD Radeon RX 6600 XT",',
|
||||
f" device_type: {device_type},",
|
||||
' backend: Vulkan,',
|
||||
]
|
||||
if multiline_selected: # pretty-print Debug:字段不在 `{` 那一行(P2-1 的形状)
|
||||
lines.append("[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo {")
|
||||
lines.extend(fields)
|
||||
lines.append("}")
|
||||
else:
|
||||
lines.append("[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo {" + "".join(fields) + " }")
|
||||
if not no_size_line:
|
||||
dims = size or SELFTEST_SIZE
|
||||
lines.append(f"[shot] {dims[0]}x{dims[1]} scale=1 -> selftest.png")
|
||||
for value in (renderer, extra_shot_renderer):
|
||||
if value is not None:
|
||||
lines.append(f'[shot] screen=gallery theme=light renderer={value} route=Detail("survival")')
|
||||
if backend is not None:
|
||||
lines.append(f"[backend] {backend} elapsed=1000ms evidence=selftest")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def synth_png(kind: str, size: tuple[int, int]) -> bytes:
|
||||
"""`content` = 有内容的合成帧(渐变 + 8px 棋盘格);`flat` = 全白。"""
|
||||
width, height = size
|
||||
|
||||
def content_row(y: int) -> bytes:
|
||||
return b"".join(
|
||||
bytes((x * 255 // max(width - 1, 1), y * 255 // max(height - 1, 1), 40))
|
||||
if ((x // 8) + (y // 8)) % 2 == 0
|
||||
else b"\x14\x14\x14"
|
||||
for x in range(width)
|
||||
)
|
||||
|
||||
if kind == "flat":
|
||||
return encode_png(width, height, lambda _y: b"\xff" * (width * 3))
|
||||
return encode_png(width, height, content_row)
|
||||
|
||||
|
||||
def run_vector(root: Path, index: int, case: dict, default_size: tuple[int, int], judges: tuple) -> list[str]:
|
||||
"""跑一条向量,返回它实际红的判据清单。`judges` = (judge_evidence, judge_refuse)。"""
|
||||
judge_evidence, judge_refuse = judges
|
||||
overrides = {key: (tuple(value) if isinstance(value, list) else value) for key, value in case.get("log", {}).items()}
|
||||
log_path = root / f"case{index:02d}.log"
|
||||
log_path.write_text(synth_log(**{"size": default_size, **overrides}), encoding="utf-8")
|
||||
png_path = root / f"case{index:02d}.png"
|
||||
png_size = tuple(case.get("png_size", default_size))
|
||||
if case.get("png") == "truncated":
|
||||
png_path.write_bytes(synth_png("content", png_size)[:200])
|
||||
elif case.get("png") is not None:
|
||||
png_path.write_bytes(synth_png(case["png"], png_size))
|
||||
report = judge_refuse(log_path, png_path) if case.get("mode") == "refuse" else judge_evidence(log_path, png_path)
|
||||
return report.problems()
|
||||
|
||||
|
||||
def run_selftest(judge_evidence, judge_refuse) -> int:
|
||||
"""跑 `gpu_shot_vectors.json` 的全部向量,逐条对 `expect_fail`(多一条少一条都算失败)。"""
|
||||
data = json.loads(VECTORS_PATH.read_text(encoding="utf-8"))
|
||||
default_size = tuple(data["size"])
|
||||
failures = 0
|
||||
with tempfile.TemporaryDirectory(prefix="ull-gpu-shot-") as tmp:
|
||||
root = Path(tmp)
|
||||
for index, case in enumerate(data["cases"], start=1):
|
||||
got = run_vector(root, index, case, default_size, (judge_evidence, judge_refuse))
|
||||
want = case["expect_fail"]
|
||||
if got != want:
|
||||
print(f"::error::自测失败:{case['label']} 期望失败 {want},实际 {got}", file=sys.stderr)
|
||||
failures += 1
|
||||
else:
|
||||
print(f"OK: {case['label']} → {'红 ' + ' / '.join(got) if got else '绿'}")
|
||||
red = sum(1 for case in data["cases"] if case["expect_fail"])
|
||||
if failures:
|
||||
return 1
|
||||
print(f"OK: GPU 截图证据判定自测通过({len(data['cases'])} 条向量:红 {red} / 绿 {len(data['cases']) - red})")
|
||||
return 0
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"note": "M249 check_gpu_shot.py --self-test 的向量。log = synth_log 的关键字覆盖(缺省键取函数默认值;null = 该行不出现;no_size_line = 只省掉 [shot] WxH 那一行;multiline_selected / empty_selected = `Selected: AdapterInfo {` 的字段换了形状;extra_shot_renderer = 再追加一行异种 renderer 的 [shot] 行);png = content/flat/truncated/null(null = 不写文件);mode = evidence/refuse;expect_fail = **期望的失败判据清单(逐条对,多一条少一条都算自测失败)**——判据被关掉或被别的东西掩盖,都在这里现形。PNG 由 check_screenshot.encode_png 现造,不进仓库。",
|
||||
"size": [200, 120],
|
||||
"cases": [
|
||||
{
|
||||
"label": "基线:wgpu + DiscreteGpu + 有内容 PNG",
|
||||
"png": "content",
|
||||
"expect_fail": []
|
||||
},
|
||||
{
|
||||
"label": "基线:IntegratedGpu 也算硬件(与 probe.rs 同口径)",
|
||||
"log": { "device_type": "IntegratedGpu" },
|
||||
"png": "content",
|
||||
"expect_fail": []
|
||||
},
|
||||
{
|
||||
"label": "变异靶子:renderer=wgpu 但 Selected 是 Cpu(WARP)",
|
||||
"log": { "device_type": "Cpu" },
|
||||
"png": "content",
|
||||
"expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"]
|
||||
},
|
||||
{
|
||||
"label": "变异靶子:Selected 是 Other(Gl 兼容层)",
|
||||
"log": { "device_type": "Other" },
|
||||
"png": "content",
|
||||
"expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"]
|
||||
},
|
||||
{
|
||||
"label": "变异靶子:Selected 换成多行 Debug(字段不在 { 那一行)",
|
||||
"log": { "multiline_selected": true },
|
||||
"png": "content",
|
||||
"expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"]
|
||||
},
|
||||
{
|
||||
"label": "变异靶子:`Selected: AdapterInfo {` 空体(字段全丢)",
|
||||
"log": { "device_type": null, "empty_selected": true },
|
||||
"png": "content",
|
||||
"expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"]
|
||||
},
|
||||
{
|
||||
"label": "变异靶子:删掉 [shot] renderer 行(只剩 Selected + backend)",
|
||||
"log": { "renderer": null },
|
||||
"png": "content",
|
||||
"expect_fail": ["截图行 renderer=wgpu"]
|
||||
},
|
||||
{
|
||||
"label": "变异靶子:日志里混入第二种 renderer(CPU 那次 >> 拼进来)",
|
||||
"log": { "extra_shot_renderer": "tiny-skia" },
|
||||
"png": "content",
|
||||
"expect_fail": ["截图行 renderer=wgpu"]
|
||||
},
|
||||
{
|
||||
"label": "回归:CPU 档出的图(renderer=tiny-skia)",
|
||||
"log": { "renderer": "tiny-skia", "device_type": null, "backend": "final=tiny-skia result=started" },
|
||||
"png": "content",
|
||||
"expect_fail": ["截图行 renderer=wgpu", "Selected: AdapterInfo 的 device_type 不是 Cpu/Other", "后端结论行 final=wgpu result=started"]
|
||||
},
|
||||
{
|
||||
"label": "回归:没有 Selected 行(wgpu 没建成设备)",
|
||||
"log": { "device_type": null },
|
||||
"png": "content",
|
||||
"expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"]
|
||||
},
|
||||
{
|
||||
"label": "回归:后端结论行是失败",
|
||||
"log": { "backend": "final=none result=failed via=refuse" },
|
||||
"png": "content",
|
||||
"expect_fail": ["后端结论行 final=wgpu result=started"]
|
||||
},
|
||||
{
|
||||
"label": "回归:完全空白帧(全白 PNG)",
|
||||
"png": "flat",
|
||||
"expect_fail": ["PNG 不是空白帧"]
|
||||
},
|
||||
{
|
||||
"label": "回归:PNG 尺寸与日志 [shot] 不符",
|
||||
"png": "content",
|
||||
"png_size": [640, 480],
|
||||
"expect_fail": ["PNG 宽高 = 日志 [shot] 的宽高"]
|
||||
},
|
||||
{
|
||||
"label": "回归:PNG 根本不存在",
|
||||
"png": null,
|
||||
"expect_fail": ["PNG 能解码且非空", "PNG 宽高 = 日志 [shot] 的宽高", "PNG 不是空白帧"]
|
||||
},
|
||||
{
|
||||
"label": "回归:PNG 半截(签名对、字节不够)",
|
||||
"png": "truncated",
|
||||
"expect_fail": ["PNG 能解码且非空", "PNG 宽高 = 日志 [shot] 的宽高", "PNG 不是空白帧"]
|
||||
},
|
||||
{
|
||||
"label": "负例基线:via=refuse + 无出图行 + 无 PNG",
|
||||
"log": { "renderer": null, "device_type": null, "no_size_line": true, "backend": "final=none result=failed via=refuse" },
|
||||
"png": null,
|
||||
"mode": "refuse",
|
||||
"expect_fail": []
|
||||
},
|
||||
{
|
||||
"label": "负例回归:拒绝日志旁边却躺着一张 PNG",
|
||||
"log": { "renderer": null, "device_type": null, "no_size_line": true, "backend": "final=none result=failed via=refuse" },
|
||||
"png": "content",
|
||||
"mode": "refuse",
|
||||
"expect_fail": ["目标 PNG 不存在(拒绝出图就该一张都没有)"]
|
||||
},
|
||||
{
|
||||
"label": "负例回归:CPU 档日志不是「拒绝」(via 缺)",
|
||||
"log": { "renderer": "tiny-skia", "device_type": null, "backend": "final=tiny-skia result=started" },
|
||||
"png": null,
|
||||
"mode": "refuse",
|
||||
"expect_fail": ["后端结论行 via=refuse", "没有出图行([shot] 尺寸 + renderer 都不该有)"]
|
||||
},
|
||||
{
|
||||
"label": "负例回归:final=… via=refuse 却仍有出图行(自相矛盾)",
|
||||
"log": { "backend": "final=none result=failed via=refuse" },
|
||||
"png": "content",
|
||||
"mode": "refuse",
|
||||
"expect_fail": ["没有出图行([shot] 尺寸 + renderer 都不该有)", "目标 PNG 不存在(拒绝出图就该一张都没有)"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
{
|
||||
"baseline": {
|
||||
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n",
|
||||
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo build --release\n",
|
||||
".github/workflows/release-publish.yml": "jobs:\n update-e2e:\n steps:\n - run: echo publish\n",
|
||||
"packaging/make_artifacts.py": "def build():\n return 'cargo build --release'\n",
|
||||
"packaging/tests/build.sh": "#!/bin/sh\ncargo build --release\n",
|
||||
"Makefile": "build:\n\tcargo build --release\n",
|
||||
"Cargo.toml": "[workspace]\nmembers = []\n",
|
||||
"crates/core/Cargo.toml": "[features]\nupdate-test-source = []\n# 注释里提到它不算违规\n"
|
||||
},
|
||||
"vectors": [
|
||||
{
|
||||
"label": "allowlist:ci.yml 的 update-e2e 一步",
|
||||
"expect": "green",
|
||||
"files": {}
|
||||
},
|
||||
{
|
||||
"label": "allowlist:同一步放进 release-build.yml",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "allowlist:步骤名改成 update-e2e-r2",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e-r2\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "allowlist:少了 --test update_e2e_test",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_tamper_test\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "allowlist:发布工作流里另起一步",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/release-publish.yml": "jobs:\n update-e2e:\n steps:\n - name: leak\n - run: cargo build --release --features update-test-source\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "allowlist:ci.yml 里另一个具名步骤",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - name: another\n - run: cargo build --release --features update-test-source\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-1 ①:末尾追加无名步骤",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - run: cargo build --release --features update-test-source\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-1 ②:末尾追加无名步骤的整个 job",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n release-from-ci:\n steps:\n - run: cargo build --release --features update-test-source\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-1 ③:flow 风格步骤",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - {name: release-build, run: \"cargo build --features update-test-source\"}\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-1 ④:步骤写在 jobs: 之前(孤儿行)",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/ci.yml": " - run: cargo build --release --features update-test-source\njobs:\n update-e2e:\n steps:\n - run: echo hi\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-1 ⑤:`run: |` 块里伪造 `name: update-e2e`",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - run: |\n echo 'name: update-e2e'\n cargo build --release --features update-test-source\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-2 ①:`#` 在引号内、真命令在后面",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: echo \"#\"; cargo build --features update-test-source\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-2 ②:shell 变量拼接(字面量仍在)",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: FEAT=\"update-test-source\"; cargo build --features \"$FEAT\"\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Cargo ①:default 把 feature 带上",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
"crates/core/Cargo.toml": "[features]\nupdate-test-source = []\n# 注释里提到它不算违规\ndefault = [\"update-test-source\"]\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Cargo ②:别的 feature 依赖它",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
"crates/core/Cargo.toml": "[features]\nupdate-test-source = []\n# 注释里提到它不算违规\nrelease = [\"update-test-source\"]\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "误杀 ①:注释里提到该 feature",
|
||||
"expect": "green",
|
||||
"files": {
|
||||
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n # 这里不许加 update-test-source\n - run: cargo build --release\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "误杀 ②:允许的那一步把 `run:` 写在 `name:` 之前(键序无关)",
|
||||
"expect": "green",
|
||||
"files": {
|
||||
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo test --release --features update-test-source --test update_e2e_test\n name: update-e2e\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "误杀 ③:`cargo hack check --all-targets`(不打开任何 feature)",
|
||||
"expect": "green",
|
||||
"files": {
|
||||
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo hack check --all-targets\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "误杀 ④:Cargo 多行 feature 声明",
|
||||
"expect": "green",
|
||||
"files": {
|
||||
"crates/core/Cargo.toml": "[features]\nupdate-test-source = [\n \"unlucky-core/update-test-source\",\n \"unlucky-cli/update-test-source\",\n]\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-2:cargo hack --feature-powerset",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo hack --feature-powerset build\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-2:cargo hack --each-feature",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo hack --each-feature build\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-2:cargo hack --all",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/release-build.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo hack --all build\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-2 扫描面:packaging/tests/build.sh",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
"packaging/tests/build.sh": "#!/bin/sh\ncargo build --features update-test-source\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-2 扫描面:.github/actions/rebuild/action.yml",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/actions/rebuild/action.yml": "runs:\n using: composite\n steps:\n - run: cargo build --features update-test-source\n shell: bash\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-2 扫描面:.github/workflows/nested/extra.yml",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/nested/extra.yml": "jobs:\n update-e2e:\n steps:\n - run: cargo build --release --features update-test-source\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-2 扫描面:Makefile",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
"Makefile": "build:\n\tcargo build --release --features update-test-source\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-1 ⑥:`name:` 写在更深缩进的 env 子项里",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - run: cargo build --release -p unlucky-cli --features update-test-source\n env:\n T: --test update_e2e_test\n HINT: |\n - name: update-e2e\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-1 ⑦:`name:` 写在 `run: |` 块标量里",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n if: runner.os == 'Windows'\n run: |\n cargo test --release -p unlucky-cli --features update-test-source \\\n --test update_e2e_test --test update_e2e_tamper_test\n - run: |\n name: update-e2e\n cargo build --release --features update-test-source --test update_e2e_test\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-1 ⑧:同一项里两个不同的 `name:`",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".github/workflows/ci.yml": "jobs:\n update-e2e:\n steps:\n - name: update-e2e\n run: cargo build --release --features update-test-source --test update_e2e_test\n name: release-build\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-2 ①:.cargo/config.toml 用 `--cfg feature=` 开该 feature",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".cargo/config.toml": "[build]\nrustflags = [\"--cfg\", 'feature=\"update-test-source\"']\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "P2-2 ②:.cargo/config.toml 设 rustc-wrapper",
|
||||
"expect": "red",
|
||||
"files": {
|
||||
".cargo/config.toml": "[build]\nrustc-wrapper = \"./tools/wrapper\"\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "误杀 ⑤:普通 .cargo/config.toml(无 feature / 无 wrapper)",
|
||||
"expect": "green",
|
||||
"files": {
|
||||
".cargo/config.toml": "[build]\njobs = 4\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"channel": "stable",
|
||||
"releases": [
|
||||
{
|
||||
"version": "9.9.9",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-01-02T03:04:05Z",
|
||||
"notes": "golden 样例(自验用,不是真实发布)",
|
||||
"force": false,
|
||||
"min_supported": "9.0.0",
|
||||
"artifacts": [
|
||||
{
|
||||
"target": "win-x64",
|
||||
"format": "portable-exe",
|
||||
"url": "unlucky-launcher_9.9.9_windows_x64.exe",
|
||||
"size": 12,
|
||||
"sha256": "8ac2dbaad20a7b318a75485359b0302d6d2550093b69296cea39db18d4836ef0"
|
||||
},
|
||||
{
|
||||
"target": "linux-x64",
|
||||
"format": "portable-zip",
|
||||
"url": "unlucky-launcher_9.9.9_linux_x64.tar.gz",
|
||||
"size": 12,
|
||||
"sha256": "dcd549d0571bb74eaee60af5c0bc702714fb0de052094336ab10e1e0c7faea0e"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user