feat(ci)+test(packaging): GPU 证据接进 CI:自测无条件跑,无 GPU 显式跳过(跳过≠通过)

新增 `.github/workflows/gpu-evidence.yml`:`selftest` 无条件跑 `--self-test` 与三个退出码
(0 过 / 1 判据不过 / 2 用法错,用真 CLI 走一遍);`probe` 只探测可用产物与硬件适配器;
`evidence` 的 job 级 `if:` 要求 `needs.probe.outputs.available == 'true'`,所以没产物时它在
Actions 里是灰色 skipped(不是一个绿色 ✓),原因写进 job summary 并打 `::warning::`。
新开文件而不是并进 `guards.yml`:那份已 399/400 行,`release-build.yml` 正好 400。

新增 `packaging/tests/gpu_shot_ci_selftest.py`:把接线口径变成机器判据(自测步不许有 `if:`、
真判据必须挂门、跳过必须 summary+`跳过`+`::warning::`+`$GITHUB_OUTPUT`、禁 `continue-on-error`、
判据命令不许 `|| true`),并带 5 条变异向量证明每种「改绿」写法都会当场红。

`packaging/docs/gpu-evidence.md` §8 记账(含「CI 上从没真跑过」)与 `packaging/README.md` 索引更新。
This commit is contained in:
AskaEth committed 2026-11-11 17:53:53 +08:00
1 parent 9234c1eeae
commit 075769c6d2
4 files changed
+503 -3

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""`gpu-evidence.yml` 的接线自检(M253):断言这道门禁**不可能被改成「总是绿」**。
为什么需要它:`check_gpu_shot.py --self-test` 只证明「尺子能量」,它管不到**接线**——
把自测步写成 `if: false`、给真实判据步加 `continue-on-error: true`、或者把命令尾巴写成
`|| true`,三种改法的结局都一样:job 变绿、一次判据都没真的跑过。本仓已经栽过四次
「守卫假绿」,所以这里把接线口径变成机器判据,而不是让下一个人读注释自律。
五条判据(对应 workflow 头部的三条规矩):
1. **自测无条件跑**:跑 `--self-test` 的那一步、以及它所在的 job,都不许有 `if:`;
2. **真实判据必须挂在门上**:跑真产物判据的步(或它的 job)的 `if:` 必须引用
`needs.<probe>.outputs.available == 'true'` —— 没有门就是「无产物时也照跑」,
跳过与通过就分不开了;
3. **跳过要留痕**:至少一步同时写着 `$GITHUB_STEP_SUMMARY`、`跳过`、`::warning::` 与
`$GITHUB_OUTPUT`(写 summary + 打黄条 + 把 available 交出去),不许静默 pass;
4. **全文件不许 `continue-on-error`**:判据红的时候它会把 job 变绿;
5. **判据命令不许吞退出码**(`|| true` / `|| exit 0` / `; true`),且真实判据步的产物路径必须
来自 `needs.probe.outputs.*`(不是写死的)。
自带 **5 条变异向量**:把上面每一种「改绿」写法注入临时副本,断言对应判据当场红、基线绿。
判据自己抓不住回归时它会先红,所以这些断言不会变成装饰品(同 `check_release_semantics.py
--self-mutation` 的做法)。
python3 packaging/tests/gpu_shot_ci_selftest.py
零第三方依赖,只借 `check_workflows_yaml.load_yaml`(它自己也负责「PyYAML 缺失 ⇒ 门禁不降级」)。
"""
from __future__ import annotations
import sys
from pathlib import Path
PACKAGING = Path(__file__).resolve().parent.parent
WORKFLOW = PACKAGING.parent / ".github/workflows/gpu-evidence.yml"
sys.path.insert(0, str(PACKAGING))
from check_workflows_yaml import load_yaml # noqa: E402
SELFTEST_MARK = "check_gpu_shot.py --self-test"
JUDGE_MARK = "check_gpu_shot.py"
GATE = "outputs.available"
SWALLOW_MARKS = ("|| true", "|| exit 0", "; true")
def steps(doc: dict) -> list[tuple[str, dict, dict]]:
"""`[(job_id, job, step)]`:只列「列表项形态」的步骤(`uses:` 的 job 没有 steps)。"""
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 check_selftest_unconditional(doc: dict) -> list[str]:
"""判据 1:自测那步不许带任何条件(job 级 or step 级)。"""
problems: list[str] = []
hits = 0
for job_id, job, step in steps(doc):
if SELFTEST_MARK not in (step.get("run") or ""):
continue
hits += 1
# 用 `in` 而不是真值判断:`if: false` 会被 YAML 解析成布尔 False,真值判断正好漏掉最想抓的那种写法。
if "if" in job:
problems.append(f"job `{job_id}` 挂了 job 级 `if:` —— 自测被判据挡住了,整条门禁可以静默不跑")
if "if" in step:
problems.append(f"job `{job_id}` 的 `--self-test` 步挂了 `if:` —— 同上,自测必须无条件跑")
if not hits:
problems.append(f"没有任何一步无条件跑 `{SELFTEST_MARK}`")
return problems
def references_probe(step: dict) -> bool:
"""这一步的产物路径是不是从 `needs.probe.outputs.*` 拿的(写死的路径会在跳过那条路上判错东西)。"""
return any("needs.probe.outputs." in str(value) for value in (step.get("env") or {}).values())
def check_real_judgement_gated(doc: dict) -> list[str]:
"""判据 2:真产物判据必须挂在 `needs.<probe>.outputs.available == 'true'` 上。"""
problems: list[str] = []
real = [
(job_id, job, step)
for job_id, job, step in steps(doc)
if JUDGE_MARK in (step.get("run") or "") and SELFTEST_MARK not in (step.get("run") or "") and references_probe(step)
]
if not real:
problems.append(
f"找不到跑真实产物判据的那一步(`{JUDGE_MARK} <日志> <PNG>`,且日志/图来自 `needs.probe.outputs.*`)—— 门禁没接上"
)
return problems
for job_id, job, step in real:
gate = str(step.get("if") or job.get("if") or "")
if GATE not in gate or "'true'" not in gate:
problems.append(
f"job `{job_id}` 的真实判据没有挂 `{GATE} == 'true'` 的门 —— 无产物时也会照跑,"
"「跳过」与「通过」就分不开了"
)
return problems
def check_skip_is_visible(doc: dict) -> list[str]:
"""判据 3:跳过必须留痕(summary + warning + 交出去的 available),不许静默 pass。"""
for _job_id, _job, step in steps(doc):
run = step.get("run") or ""
if "GITHUB_STEP_SUMMARY" in run and "跳过" in run and "::warning::" in run and "GITHUB_OUTPUT" in run:
return []
return ["没有任何一步同时写着 `$GITHUB_STEP_SUMMARY` + `跳过` + `::warning::` + `$GITHUB_OUTPUT` —— 跳过会变成静默 pass"]
def check_no_continue_on_error(doc: dict) -> list[str]:
"""判据 4:`continue-on-error` 会把红的判据变绿,全文件禁用。"""
problems: list[str] = []
for job_id, job, _step in steps(doc):
if job.get("continue-on-error") is True:
problems.append(f"job `{job_id}` 带 `continue-on-error: true`")
for job_id, _job, step in steps(doc):
if step.get("continue-on-error") is True:
problems.append(f"job `{job_id}` 的步骤 `{step.get('name') or step.get('uses')}` 带 `continue-on-error: true` —— 判据红也会变成绿")
return problems
def check_no_swallowed_exit_code(doc: dict) -> list[str]:
"""判据 5:判据命令不许吞退出码(`|| true` / `; true` 会把红判据变成绿)。"""
problems: list[str] = []
for job_id, _job, step in steps(doc):
run = step.get("run") or ""
if JUDGE_MARK not in run or SELFTEST_MARK in run:
continue
name = step.get("name") or f"job {job_id} 里那一步"
for mark in SWALLOW_MARKS:
if mark in run:
problems.append(f"`{name}` 的命令里出现 `{mark}` —— 退出码被吞掉,判据红也绿")
return problems
CHECKS = {
"自测无条件": check_selftest_unconditional,
"真实判据挂在门上": check_real_judgement_gated,
"跳过留痕": check_skip_is_visible,
"禁用 continue-on-error": check_no_continue_on_error,
"退出码不被吞": check_no_swallowed_exit_code,
}
#: 变异向量:`(说明, 原文片段, 替换成, 应该红的判据)`。原文片段找不到 = 向量过期,同样算失败。
VECTORS: list[tuple[str, str, str, str]] = [
(
"把 evidence 的门删掉(真实判据无条件跑)",
" if: needs.probe.outputs.available == 'true'\n",
"",
"真实判据挂在门上",
),
(
"给真实判据步加 continue-on-error: true",
" - name: 判定(0 过 / 1 判据不过 / 2 用法错;退出码即结论,不许被 || true 吞掉)\n env:",
" - name: 判定(0 过 / 1 判据不过 / 2 用法错;退出码即结论,不许被 || true 吞掉)\n continue-on-error: true\n env:",
"禁用 continue-on-error",
),
(
"判据命令后面加 || true",
' python3 packaging/check_gpu_shot.py "$LOG" "$PNG"\n',
' python3 packaging/check_gpu_shot.py "$LOG" "$PNG" || true\n',
"退出码不被吞",
),
(
"给自测步加 if: false(自测不再无条件跑)",
" - name: 判据实现自测(19 条向量,数据在 packaging/tests/gpu_shot_vectors.json)\n run:",
" - name: 判据实现自测(19 条向量,数据在 packaging/tests/gpu_shot_vectors.json)\n if: false\n run:",
"自测无条件",
),
(
"把跳过改成静默绿(warning → notice)",
'echo "::warning::GPU 证据',
'echo "::notice::GPU 证据',
"跳过留痕",
),
]
def evaluate(yaml, text: str) -> dict[str, list[str]]:
doc = yaml.safe_load(text)
if not isinstance(doc, dict) or "jobs" not in doc:
return {name: ["workflow 不是一个带 jobs 的 YAML 映射"] for name in CHECKS}
return {name: check(doc) for name, check in CHECKS.items()}
def main() -> int:
yaml = load_yaml()
if not WORKFLOW.is_file():
print(f"::error file={WORKFLOW}::接线自检的靶子不存在 —— GPU 证据门禁是不是被删了?", file=sys.stderr)
return 1
text = WORKFLOW.read_text(encoding="utf-8")
failures = 0
baseline = evaluate(yaml, text)
for name, problems in baseline.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}」")
for label, old, new, expected in VECTORS:
if old not in text:
failures += 1
print(f"FAIL: 变异向量过期(原文片段已不在 {WORKFLOW.name} 里):{label}", file=sys.stderr)
continue
mutated = evaluate(yaml, text.replace(old, new, 1))
got = [name for name, problems in mutated.items() if problems]
if expected not in got:
failures += 1
print(f"FAIL: 变异「{label}」没被抓到(期望判据「{expected}」红,实际红的是 {got or '无'})", file=sys.stderr)
else:
print(f"OK: 变异「{label}」→ 判据「{expected}」当场红")
if failures:
print(f"FAIL:{failures} 处没过({WORKFLOW.name})", file=sys.stderr)
return 1
print(f"ok: {len(CHECKS)} 条接线判据全过,{len(VECTORS)} 条变异向量全部被抓({WORKFLOW.name})")
return 0
if __name__ == "__main__":
raise SystemExit(main())