新增 `.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` 索引更新。
227 lines
10 KiB
Python
227 lines
10 KiB
Python
#!/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())
|