Files
Unlucky-Launcher/packaging/tests/gpu_shot_ci_probe.py
T
AskaEth 2e9702790d fix(ci)+test(packaging): 接线自检不再给假安全证书:19 条变异重放全红,dev 线接上
r1 复审 P1-1:上一版自检对外宣称「不可能被改成总是绿」,实测 19 条变异漏 11 条
(最要命的是把 `available` 写成恒 false —— 门禁永久关闭而每次运行都绿)。根因是三条
判据只是字符串存在性检查、自测步与自检步被整条豁免、`is True` 挡不住 `'true'`。

`packaging/tests/gpu_shot_ci_selftest.py` 重写为 6 条判据:
① 五步按 `id:` 必须在场且无条件(`vectors`/`exitcodes`/`wiring`/`detect`/`judge`,
   `exitcodes` 还得真断言 `expect 0/1/2`);
② 解析 `if:` 表达式后**求值**判「判定挂门」(`available` 取 `'true'`/`'false'`/`''`
   必须真/假/假,认不出一律红)—— `|| always()` 这类文本层面的门现在真红了;
③ 两种调用形态都必须在场且产物来自 `needs.probe.outputs.*`;
④ 跳过路径**实跑 7 种输入**(新增 `packaging/tests/gpu_shot_ci_probe.py`),
   断言 `$GITHUB_OUTPUT` 的 `available`、summary 文案、`::warning::` 与三条红线的 exit≠0;
⑤ `continue-on-error` 按 YAML 布尔语义判(`'true'` / `${{ true }}` 也算开启);
⑥ 扫所有 `python`+`packaging/` 的 `run`(自测步、自检步都在内),`|| true` / `; true` /
   `|| exit 0` / 裸 `exit 0` 全算吞。
变异向量搬到 `packaging/tests/gpu_shot_ci_vectors.json`:21 条,按解析后的结构注入。

P2-1:`gpu-evidence.yml` 加 `workflow_call:`,`dev-guards.yml` 里 `uses:` 同一份 ——
直推 dev 时 selftest 那一半现在会跑(evidence 在 dev 上照样显式跳过)。
P2-2:跳过摘要与 `gpu-evidence.md §8` 改成唯一可行的补救路径(把产物提交进被 dispatch
的 ref,`git add -f`),并写明「就地拷文件在 CI 上做不到」。
措辞:三处「不可能被改成总是绿」改成能复跑的覆盖面 + 边界。
2026-11-11 18:08:31 +08:00

146 lines
7.8 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 复审返工):把 workflow 里的 `run:` 原文抽出来真跑。
为什么单独一份:判据脚本自己守 ≤400 行硬线,而这里全是「把 `run:` 落盘、用桩 env 起 bash、读
`$GITHUB_OUTPUT` / `$GITHUB_STEP_SUMMARY`」的**执行脚手架**,与「哪条判据该红」的判断无关
(同 `gpu_shot_selftest.py` 与 `check_gpu_shot.py` 的分工)。
被谁用:`packaging/tests/gpu_shot_ci_selftest.py` 的判据 4。它一次跑 7 种输入,因为字符串存在性
检查被 r1 复审证伪过:旧版只要求「同一步的 run 里同时出现 4 个字符串」,于是把 `available` 写成
恒 `false`(门禁永久关闭、每次运行都绿)它照样绿 —— **文案都在,执行路径却根本没走到**。
7 种输入(前 4 条断言「跳过要留痕」,后 3 条断言「红线不许退化成跳过」):
| # | 输入 | 必须发生什么 |
|---|------|-------------|
| 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) |
不降级:找不到能跑的 `bash` 就**判红**(「认不出/跑不了」≠「没问题」)。
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
_CACHE: dict[str, list[str]] = {}
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):
"""跑一份 `run:` 原文,返回 `(CompletedProcess, GITHUB_OUTPUT 字典, GITHUB_STEP_SUMMARY 文本)`。"""
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, "-e", str(script)], cwd=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]:
"""判据 4:`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]