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 上做不到」。
措辞:三处「不可能被改成总是绿」改成能复跑的覆盖面 + 边界。
This commit is contained in:
AskaEth committed 2026-11-11 18:08:31 +08:00
1 parent 075769c6d2
commit 2e9702790d
7 files changed
+483 -162

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
#!/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]