Files
Unlucky-Launcher/packaging/tests/gpu_shot_ci_probe.py
T
AskaEth a5d1dec495
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
release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
2026-11-11 21:41:38 +08:00

216 lines
12 KiB
Python
Raw Blame History

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