fix(ci)+test(packaging): 关掉「门禁关死而自检全绿」三条路:outputs 绑定、判据步真跑、自检步指向

r2 复审:r1 那 11 条 + M3 已闭合(复审自己重放 12/12 红),但它新造的 40 条变异仍有 11 条漏网,
其中三条是「1 行改绿」:把 `probe.outputs.available` 改指向 `reason`/字面量(门永久关、每次绿);
判据步的退出码在标记表之外被吞(`|| echo 先忽略` / `; :` / `| cat` / `if !` / `true # 原行`,
复审真 bash 实测 exit 0);自检步被指向另一个脚本(自检根本不跑)。

三条 P1:
- 新判据 9:`probe.outputs.{available,log,png}` 必须逐个等于 `${{ steps.detect.outputs.<同名> }}`;
- 新判据 5:把 `id: judge` 的 `run` 原文真跑两次 —— 正例必须 exit 0、CPU 档日志必须 exit≠0,
  且 `bash -e` 与**裸 bash** 各跑一遍(`; :` 只在后者现形,实测 `bash -e`=1 / 裸 bash=0)。
  这一条一次封掉整类「吞码」,不依赖标记表;
- 判据 2:`id: wiring` 的 `run` 必须含 `gpu_shot_ci_selftest.py`,且 `vectors`/`wiring` 必须是**单条命令**
  (不许换行/管道/`;`/`&`/反引号/`#`/`$(`)⇒ `|| echo 先忽略` 无从下手。

两条 P2:
- 新判据 10:`on:` 必须同时有 `push`(含 `main`)/`pull_request`/`workflow_call`;
- 「载体依赖」这条边界补进四处(自检 docstring / gpu-evidence.md §8 / README / workflow 头注释):
  整个 workflow 被删掉就没有东西会跑自检,`guards.yml` 399/400 行塞不进去。

止步线(写进 docstring 与 §8):封闭清单 10 类 / 33 条变异向量;明确不覆盖「改判据脚本」「删掉整个
workflow」「求值器与 GitHub 语义差」「判据 4 的合法调用替换」;本文档不宣称「不可能被改成总是绿」;
**自 2026-11-11 起停止加固,扩项须由人类新开 mission**。

新增 `packaging/tests/gpu_shot_ci_expr.py`(`if:` 小求值器,判据脚本仍守 ≤400 行:377/215/64)。
This commit is contained in:
AskaEth committed 2026-11-11 18:31:30 +08:00
1 parent 2e9702790d
commit 8722c5a0ab
7 files changed
+358 -139

No files matched your search

+4 -3
View File
@@ -70,9 +70,10 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 |
| `packaging/check_gpu_shot.py` | GPU 截图证据判定(M249):`[shot] … renderer=wgpu` + `Selected: AdapterInfo` 的 `device_type ∉ {Cpu, Other}` + PNG 非空/尺寸对账/非空白,逐条点名;`--expect-refuse` 判拒绝降级出图;`--self-test` 跑 `tests/gpu_shot_vectors.json` 的 19 条向量(脚手架 `tests/gpu_shot_selftest.py`)。CI 接线见下一行(M253),口径与能证/不能证见 `docs/gpu-evidence.md` | ≤400 |
| `.github/workflows/gpu-evidence.yml` | GPU 证据门禁接线(M253):`selftest` **无条件**跑 `--self-test` + 三个退出码 0/1/2;`probe` 探测可用产物与硬件适配器;`evidence` 只在 `needs.probe.outputs.available == 'true'` 时跑真判据 —— **无 GPU / 无产物时它整块显示 skipped(不是绿色 ✓)**,原因写进 job summary 并打 `::warning::`(跳过 ≠ 通过)。新开文件而不是并进 `guards.yml`:那份已 399/400 行,`release-build.yml` 正好 400。**dev 线复用**(r1 P2-1):`dev-guards.yml` 里 `uses:` 同一份,dev 上只落 `selftest` 那一半 | ≤400 |
| `packaging/tests/gpu_shot_ci_selftest.py` | 上一条的**接线自检**(M253,r1 返工):6 条判据 —— ① 五步按 `id:` 必须在场且无条件(`vectors`/`exitcodes`/`wiring`/`detect`/`judge`,`exitcodes` 还得真断言 0/1/2);② **解析 `if:` 表达式求值**判「判定挂门」(`available` 取 `'true'`/`'false'`/`''` 必须真/假/假,认不出即红);③ 两种调用形态都在场且产物来自 `needs.probe.outputs.*`;④ 跳过路径**实跑 7 种输入**(`tests/gpu_shot_ci_probe.py`);⑤ `continue-on-error` 按 YAML 布尔语义判;⑥ 扫所有 `python`+`packaging/` 的 `run` 不许吞退出码。外加 **21 条变异向量**(`tests/gpu_shot_ci_vectors.json`,复审那 19 条 + 2 条)逐条必须当场红 | ≤400 |
| `packaging/tests/gpu_shot_ci_probe.py` | 上面判据 ④ 的**执行脚手架**:把 `probe` 步的 `run:` 原文落盘、用桩 env 起 bash,断言 `$GITHUB_OUTPUT` 的 `available` / job summary 文案 / `::warning::`,以及三条「点了名却找不到 ⇒ 必须红」的红线;找不到能跑的 bash 即判红(不降级) | ≤400 |
| `packaging/tests/gpu_shot_ci_vectors.json` | 接线自检的变异向量:21 条,按**解析后的结构**注入(`step_delete`/`job_delete`/`step_set`/`job_set`/`run_append`/`run_replace`),每条写明应红的判据名 | ≤400 |
| `packaging/tests/gpu_shot_ci_selftest.py` | 上一条的**接线自检**(M253,r1+r2 返工):10 条判据 —— ① 五步按 `id:` 必须在场且无条件(`vectors`/`exitcodes`/`wiring`/`detect`/`judge`,`exitcodes` 还得真断言 0/1/2);② 自测步必须**指向本脚本且是单条命令**(`|| echo 先忽略` 无从下手);③ **解析 `if:` 表达式求值**判「判定挂门」(`available` 取 `'true'`/`'false'`/`''` 必须真/假/假,认不出即红);④ 两种调用形态都在场且产物来自 `needs.probe.outputs.*`;⑤ **判据步真跑两次**(正例必须 0、CPU 档必须非 0,`bash -e` 与裸 bash 两种模式);⑥ 跳过路径**实跑 7 种输入**;⑦ `continue-on-error` 按 YAML 布尔语义判;⑧ 吞码标记表(兜底);⑨ `probe.outputs.*` 必须逐个绑到 `steps.detect.outputs.*`;⑩ `on:` 必须同时有 `push`(含 `main`)/`pull_request`/`workflow_call`。外加 **33 条变异向量**(`tests/gpu_shot_ci_vectors.json`:r1 复审表 20 条 + 1 条补充 + r2 复审 12 条)逐条必须当场红。**不覆盖**:不自守、载体依赖:整个 `gpu-evidence.yml` 被删掉/替换后,**没有任何东西会跑这个自检** —— 守门人住在被守对象自己的文件里(`guards.yml` 已经 399/400 行,塞不进去)。求值器与 GitHub 语义的差异、判据 4 的「合法调用替换」。**自 2026-11-11 起停止加固**,扩项须由人类新开 mission | ≤400 |
| `packaging/tests/gpu_shot_ci_expr.py` | 上面判据 ③ 的**小求值器**:给 `needs.probe.outputs.available` 取 `'true'`/`'false'`/`''`,门必须分别判真/假/假 —— 门还挂不挂由**语义**决定,不由子串决定。只认 `==`/`!=`/`&&`/`||`/括号/字符串/`needs.<job>.outputs.<名>`/`true|false|null`/四个无参函数;`!` 与其余写法一律抛错(调用方按红处理)。不是 GitHub 语义的替身,是它的保守子集 | ≤400 |
| `packaging/tests/gpu_shot_ci_probe.py` | 上面判据 ⑤⑥ 的**执行脚手架**:把 `detect` / `judge` 两步的 `run:` 原文落盘、用桩 env 起 bash。`detect` 跑 7 种输入,断言 `$GITHUB_OUTPUT` 的 `available` / summary 文案 / `::warning::` / 三条「点了名却找不到 ⇒ 必须红」;`judge` 跑两次(正例必须 0、CPU 档必须非 0,两种 bash 模式),一次封掉 `\|\| echo` / `; :` / 管道 / `if !` / `true # 原行` 整类「绿而没判」。找不到能跑的 bash 即判红(不降级) | ≤400 |
| `packaging/tests/gpu_shot_ci_vectors.json` | 接线自检的变异向量:**33 条**,按**解析后的结构**注入(`step_delete`/`job_delete`/`step_set`/`job_set`/`path_set`/`trigger_set`/`run_append`/`run_replace`,`also` 可叠加),每条写明应红的判据名;来源见文件里的 `_note` | ≤400 |
| `packaging/tests/gpu_shot_vectors.json` | `check_gpu_shot.py --self-test` 的向量:19 条(红 16 / 绿 3),每条给出**期望的失败判据清单**,数据与代码分开 | ≤400 |
| `packaging/tests/gpu_shot_selftest.py` | 上面那 19 条向量的执行器(按真实行格式手拼日志 + 用 `check_screenshot.encode_png` 现造 PNG):判据脚本自己守 ≤400 行,脚手架放这里 | ≤400 |
| `packaging/check_publish_slots.py` | 发布名单守卫(M164/M184):名单 ⇄ 产物两个方向 + `mode: ci` 槽位无产物的警告;`--self-test` 注入 11 条回归 | ≤400 |
+30 -14
View File
@@ -193,24 +193,40 @@ GPU 证据:无硬件适配器(/dev/dri 下没有 renderD*,也没有可用
就地往 workspace 里拷文件(不提交、靠上一步的产物)**在这个设计里做不到**:没有 artifact 传递环节。
**接线自检保证什么**:`packaging/tests/gpu_shot_ci_selftest.py` 用 6 条判据 + 21 条变异向量把上面这些
口径钉住 —— 判据 1 按 `id:` 认「五步必须在场」(`vectors` / `exitcodes` / `wiring` / `detect` / `judge`,
删步或加 `if:` 即红);判据 2 **解析 `if:` 表达式后求值**(`available` 取 `'true'`/`'false'`/`''` 分别
必须真/假/假,认不出的表达式一律判红);判据 3 要求两种调用形态都在场;判据 4 把 `probe` 的 `run` 原文
**实跑 7 种输入**;判据 5 按 YAML 布尔语义判 `continue-on-error`(`'true'` / `${{ true }}` 都算开启);
判据 6 扫所有 `python`+`packaging/` 的 `run`(含自测步与自检步自己)。
**它保证不了**:连判据一起改(它不自守)、`if:` 的 GitHub 语义(那是本脚本的小求值器模拟的)、以及
真 Actions 里的 job skip / required check 行为。r1 那一版把「不可能被改成总是绿」写进本文件,而实测
19 条变异漏 11 条 —— 所以这里只写**能复跑的覆盖面**(21 条向量,逐条见
`packaging/tests/gpu_shot_ci_vectors.json`),不再给绝对化的安全证书。
**接线自检的覆盖面**:`packaging/tests/gpu_shot_ci_selftest.py` 用 **10 条判据 + 33 条变异向量**
(r1 复审表 20 条 + 1 条补充 + r2 复审 12 条)把上面这些口径钉住,逐条见
`packaging/tests/gpu_shot_ci_vectors.json`:
1. 五步按 `id:` 必须在场且无条件(`vectors`/`exitcodes`/`wiring`/`detect`/`judge`),`exitcodes` 还得真断言 0/1/2;
2. 自测步必须**指向本脚本且是单条命令**(`|| echo 先忽略` 无从下手);
3. **解析 `if:` 表达式后求值**判「判定挂门」(`available` 取 `'true'`/`'false'`/`''` 必须真/假/假,认不出即红);
4. 两种调用形态都在场,且产物来自 `needs.probe.outputs.*`;
5. **判据步真跑两次**(正例必须 0、CPU 档必须非 0,`bash -e` 与裸 bash 两种模式都跑);
6. 跳过路径**实跑 7 种输入**;
7. `continue-on-error` 按 YAML 布尔语义判(`'true'` / `${{ true }}` 都算开启);
8. 吞码标记表(`|| true` / `; true` / `|| exit 0` / 裸 `exit 0`,兜底用);
9. `probe.outputs.*` 必须逐个绑到 `${{ steps.detect.outputs.* }}`;
10. `on:` 必须同时有 `push`(含 `main`)/`pull_request`/`workflow_call`。
**它不覆盖**:连判据一起改(它不自守);`if:` 的 GitHub 语义(那是本脚本的小求值器模拟的);真 Actions 里的
job skip / required check 行为;判据 4 的「合法调用替换」(后果是假红不是假绿);`exitcodes` 见证步自己的
断言被削弱;判据 6 那 7 种输入之外的新分支。
**载体依赖**:整个 `gpu-evidence.yml` 被删掉/替换后,**没有任何东西会跑这个自检** —— 守门人住在被守对象
自己的文件里(`guards.yml` 已经 399/400 行,塞不进去)。
本文件**不宣称**「不可能被改成总是绿」:r1 那一版这么写过,实测 19 条变异漏 11 条;r2 那一版
(6 条判据)实测 40 条新变异漏 11 条。所以这里只写**能复跑的封闭清单**(10 类 / 33 条向量)。
**这条门禁自 2026-11-11 起停止加固**:再往下是「守卫守守卫守守卫」,边际收益很低;要扩覆盖面必须由
人类新开 mission。
### 8.1 这一节**没验**的部分
- **CI 上从没真跑过**:本 workflow 是本地写的,**一次 GitHub Actions 运行都没有**(未 push)。
本地验的是两件事:① 把三个 step 的 `run:` 原文抽出来用 bash 跑(跳过 / 约定成对 / 约定半套 /
dispatch 成对 / 点名不存在 / 正例缺 PNG / 负例 —— 这 7 条已由判据 4 **每次自检都重跑**);
② `evidence` 的判据命令本地实跑过 0 与 1。**这不是 CI 实测**:`if:` 求值、job skip 在 UI 上的呈现、
`needs` 的输出传递在真 Actions 里仍未跑过。
本地验的是两件事,且都**已固化成每次自检都重跑的功能断言**:① `detect` 的 `run` 原文跑 7 种输入
(跳过 / 约定成对 / 约定半套 / dispatch 成对 / 点名不存在 / 正例缺 PNG / 负例 —— 判据 6);
② `judge` 的 `run` 原文跑两次(正例必须 0、CPU 档必须非 0,`bash -e` 与裸 bash 各跑一遍 —— 判据 5)。
**这不是 CI 实测**:`if:` 求值、job skip 在 UI 上的呈现、`needs` 的输出传递在真 Actions 里仍未跑过。
- **没在真 GPU runner 上跑过**:`evidence` 的真判定只在本地用合成向量走过(0 与 1 都到达过),
「真产物 + 真 GPU runner」这个组合没实测。
- **没验「跳过是否满足 required status check」**:本 workflow 没挂成必需检查;GitHub 对 skipped job
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""GitHub `if:` 表达式的小求值器(M253 / r2 复审返工,判据 3 用)。
为什么需要它:字符串存在性判据被两轮复审证伪过 —— `if: needs.probe.outputs.available == 'true' || always()`
用 `in` 判照样绿,`if: true` 也绿。所以这里**真求值**:给 `needs.probe.outputs.available` 分别取
`'true'` / `'false'` / `''`,门必须分别判真 / 假 / 假 —— 门到底还挂不挂,由语义决定,不由子串决定。
**只认一小撮语法,其余一律抛 `ConditionError`(调用方按红处理,宁可假红)**:
`==` / `!=` / `&&` / `||` / 括号 / 单引号字符串 / 数字 / `needs.<job>.outputs.<名>` / `true|false|null` /
`always()|success()|failure()|cancelled()`(后四个按「假定上游成功」取常量)。**不支持 `!`**:GitHub 与
Python 的 `!` 优先级不同,认不出比误判安全。不是 GitHub 语义的替身,是它的一个保守子集。
"""
from __future__ import annotations
import re
AVAILABLE_IDENT = "needs.probe.outputs.available"
CALL_FUNCS = {"always": True, "success": True, "failure": False, "cancelled": False}
_TOKEN_RE = re.compile(r"(?P<string>'[^']*')|(?P<number>\d+)|(?P<ident>[A-Za-z_][\w.\-]*)|(?P<op>==|!=|&&|\|\||[()!])")
class ConditionError(Exception):
"""`if:` 表达式认不出 —— 认不出即判红,绝不默认放过。"""
def evaluate_condition(raw, available: str) -> bool:
"""把 GitHub 的 `if:` 表达式求值。只认一小撮语法,其余抛 `ConditionError`(= 判红)。"""
text = str(raw).strip()
if text.startswith("${{") and text.endswith("}}"):
text = text[3:-2].strip()
for name, value in CALL_FUNCS.items():
text = re.sub(rf"\b{name}\s*\(\s*\)", "true" if value else "false", text)
if re.search(r"\b(" + "|".join(CALL_FUNCS) + r")\s*\(", text):
raise ConditionError("函数只支持不带参数的形式")
parts: list[str] = []
pos = 0
while pos < len(text):
if text[pos].isspace():
pos += 1
continue
match = _TOKEN_RE.match(text, pos)
if not match:
raise ConditionError(f"认不出的字符 {text[pos]!r}")
pos = match.end()
token = match.group()
if match.lastgroup in ("string", "number"):
parts.append(token)
elif match.lastgroup == "ident":
if token == AVAILABLE_IDENT:
parts.append("__available")
elif token in ("true", "false", "null"):
parts.append({"true": "True", "false": "False", "null": "None"}[token])
else:
raise ConditionError(f"认不出的标识符 `{token}`")
else:
if token == "!":
raise ConditionError("不支持 `!`(GitHub 与 Python 的优先级不同,宁可认不出)")
parts.append({"&&": "and", "||": "or"}.get(token, token))
source = " ".join(parts)
try:
return bool(eval(source, {"__builtins__": {}}, {"__available": available})) # noqa: S307 - 词表是白名单,见上
except Exception as error: # noqa: BLE001 - 任何求值失败都按「认不出」处理
raise ConditionError(f"求值失败({error}):`{source}`") from None
+80 -10
View File
@@ -1,15 +1,15 @@
#!/usr/bin/env python3
"""`gpu-evidence.yml` 的**功能断言**部分(M253 / r1 复审返工):把 workflow 里的 `run:` 原文抽出来真跑。
"""`gpu-evidence.yml` 的**功能断言**部分(M253 / r1+r2 复审返工):把 workflow 里的 `run:` 原文抽出来真跑。
为什么单独一份:判据脚本自己守 ≤400 行硬线,而这里全是「把 `run:` 落盘、用桩 env 起 bash、读
`$GITHUB_OUTPUT` / `$GITHUB_STEP_SUMMARY`」的**执行脚手架**,与「哪条判据该红」的判断无关
`$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 条断言「红线不许退化成跳过」):
**A. `detect` 步(`probe_problems`)—— 跳过要留痕、红线不许退化成跳过。**
字符串存在性检查被 r1 复审证伪过:旧版只要求「同一步的 run 里同时出现 4 个字符串」,于是把
`available` 写成恒 `false`(门禁永久关闭、每次运行都绿)它照样绿 —— **文案都在,执行路径却没走到**。
| # | 输入 | 必须发生什么 |
|---|------|-------------|
@@ -21,18 +21,36 @@
| 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:
@@ -53,13 +71,17 @@ def _find_bash() -> str | None:
return None
def _run(bash: str, script: Path, workdir: Path, env_extra: dict):
"""跑一份 `run:` 原文,返回 `(CompletedProcess, GITHUB_OUTPUT 字典, GITHUB_STEP_SUMMARY 文本)`。"""
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, "-e", str(script)], cwd=str(workdir), env=env, capture_output=True, text=True, encoding="utf-8", errors="replace")
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():
@@ -132,7 +154,7 @@ def _probe_scenarios(bash: str, script_text: str) -> list[str]:
def probe_problems(script_text: str) -> list[str]:
"""判据 4:`id: detect` 那一步的 `run` 原文实跑 7 种输入(带缓存,同文本只跑一次)。"""
"""判据 A:`id: detect` 那一步的 `run` 原文实跑 7 种输入(带缓存,同文本只跑一次)。"""
if not script_text.strip():
return ["`id: detect` 那一步没有 `run:` —— 跳过路径无从执行"]
if script_text not in _CACHE:
@@ -143,3 +165,51 @@ def probe_problems(script_text: str) -> list[str]:
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]
+147 -94
View File
@@ -1,35 +1,66 @@
#!/usr/bin/env python3
r"""`gpu-evidence.yml` 的**接线自检**(M253 / r1 复审返工):把「这道门禁怎么才算没被改绿」变成判据。
r"""`gpu-evidence.yml` 的**接线自检**(M253 / r1+r2 复审返工):把「这道门禁怎么才算没被改绿」变成判据。
历史教训(必须留着):r1 那一版宣称「不可能被改成总是绿」,复审独立造了 19 个「改成总是绿」的变异,
**11 个它照样绿** —— 最要命的是把 `available` 写成恒 `false`:门禁永久关闭,而 push/PR 本来就跳过,
**肉眼与机器都看不出**。原因有三:判据 2/3 本质是字符串存在性检查、判据 5 把自测步整条豁免且不扫
`exit 0`、判据 4 用 `is True` 挡不住 `'true'` / `${{ true }}`。那一版给的是一张**假的安全证书**。
历史(留着,防止下一个人再写绝对化结论):
- r1 版宣称「不可能被改成总是绿」,复审造 19 条变异**漏 11 条**(最要命:`available` 恒 false ⇒ 门永久
关闭而每次绿)—— 根因是判据只做字符串存在性检查、自测步被整条豁免、`is True` 挡不住 `'true'`。
- r2 版改成 6 条判据后,复审 40 条新变异**仍漏 11 条**:job outputs 的 `available` 绑到 `reason`/字面量
(门永久关、每次绿)、判据步 `|| echo "先忽略"` / `; :` / `| cat` / `if !` / `true # 原行`(**绿而没判**,
真 bash 实测 exit 0)、自检步被指向另一个脚本(自检根本不跑)、`on:` 触发面被删。
教训:**「不吞退出码」不能靠标记表**,得把 `run` 原文真跑一遍。
现在六条判据,能结构化的不靠字符串、能跑的不靠读文本:
现在十条判据,能结构化的不靠字符串、能跑的不靠读文本:
| # | 判据 | 怎么判 | 抓的是什么 |
|---|------|--------|-----------|
| 1 | 五步必须在场 | 按 `id:` 找(`vectors` / `exitcodes` / `wiring` / `detect` / `judge`),删掉或复制即红;`vectors/exitcodes/wiring/detect` 不许有 `if:`(step 或 job 级);`exitcodes` 必须真的断言 `expect 0/1/2` 三个码 | 删步、`if: false`、把自测关掉 |
| 2 | 判定必须挂门 | **解析 `if:` 表达式后求值**:`needs.probe.outputs.available` 取 `'true'` 时必须为真、取 `'false'` 与 `''` 时必须为假。认不出的表达式一律判红(宁可假红) | 加 `\|\| always()`、改成 `if: true`、删门、`!` 之类认不出的写法 |
| 3 | 判定用的是探测给的产物 | `run` 里**两种调用形态都必须在场**(`check_gpu_shot.py "$LOG" "$PNG"` 与 `... "$LOG" --expect-refuse`),且 `env` 来自 `needs.probe.outputs.*` | 写死路径、判错文件、把某一分支换成 `true`(那个分支啥都没判就绿) |
| 4 | 跳过路径**真跑** | 把 `detect` 步的 `run` 原文抽到临时目录、用桩 env **实跑 7 种输入**,断言 `$GITHUB_OUTPUT` 里的 `available`、job summary 文案、`::warning::`,以及三条红线必须 `exit≠0` | `available` 恒 false、跳过不留痕、红线退化成跳过 |
| 5 | 禁 `continue-on-error` | 按 **YAML 布尔语义**(`true` / `'true'` / `${{ true }}` / 任意表达式都算开启,只有 `false`/`'false'`/`${{ false }}`/`0` 算关闭) | 带引号、表达式形式 |
| 6 | 不吞退出码 | 扫**所有** `run` 里同时出现 `python`/`python3` 与 `packaging/` 的步(自测步与自检步都在内),`\|\| true` / `; true` / `\|\| exit 0` / 裸 `exit 0` 全算吞 | 自测步与自检步被整条豁免、裸 `exit 0` |
| # | 判据 | 怎么判 |
|---|------|--------|
| 1 | 五步必须在场 | 按 `id:` 认 `vectors`/`exitcodes`/`wiring`/`detect`/`judge`,各恰好一步;前四步不许有 `if:`(step 或 job 级);`exitcodes` 必须真断言 `expect 0/1/2` |
| 2 | 自测步跑的是对脚本、且是单条命令 | `wiring` 的 `run` 必须含 `gpu_shot_ci_selftest.py`;`vectors`/`wiring` 的 `run` 不许有换行、`\|`、`&`、`;`、反引号、`#`、`$(` —— 否则红了之后一行 `\|\| echo 先忽略` 就能关掉它 |
| 3 | 判定必须挂门 | **解析 `if:` 表达式后求值**:`available` 取 `'true'` 必须真、`'false'`/`''` 必须假;job 级与 step 级各自都要满足;认不出的表达式一律红 |
| 4 | 判定用探测给的产物 | `run` 里两种调用形态都在场(`"$LOG" "$PNG"` 与 `"$LOG" --expect-refuse`),`env` 来自 `needs.probe.outputs.*` |
| 5 | 判据步**真跑两次** | `judge` 的 `run` 原文实跑:正例成对必须 `exit 0`,CPU 档日志(`renderer=tiny-skia`)必须 `exit≠0` —— 一次封掉整类「吞码」 |
| 6 | 跳过路径**真跑 7 种输入** | `detect` 的 `run` 原文实跑:`available` 的值、summary 文案、`::warning::`、三条红线必须 `exit≠0` 且不写 outputs |
| 7 | 禁 `continue-on-error` | 按 YAML 布尔语义:`true` / `'true'` / `${{ true }}` / 任意表达式都算开启 |
| 8 | 不吞退出码(标记表) | 扫所有 `python`+`packaging/` 的 `run`:`\|\| true` / `; true` / `\|\| exit 0` / 裸 `exit 0`(判据 5 的功能断言才是主守,这张表只是兜底) |
| 9 | `probe.outputs` 必须绑到 `detect` | `available`/`log`/`png` 三个键的值必须**逐个**等于 `${{ steps.detect.outputs.<同名> }}` |
| 10 | 触发面 | `on:` 必须同时有 `push`(含 `main`)、`pull_request`、`workflow_call` |
再叠一层:`packaging/tests/gpu_shot_ci_vectors.json` 里 **21 条变异向量**(r1 复审那张表里的 20 条 + 一条
「把判定步的命令换成 `true`」),每条按**解析后的结构**注入(`step_delete` / `job_delete` / `step_set` / `job_set` /
`run_append` / `run_replace`),每条都必须让对应判据当场红、基线必须全绿。
## 覆盖的「改绿」类别(封闭清单:10 类,33 条变异向量)
**保证不了什么(别读成免责声明,读成边界)**:
- 这些判据本身不是自守的:能改这个文件的人也能改判据。它防的是**顺手改绿**,不是恶意改绿。
- `if:` 是我自己写的小求值器,不是 GitHub 的语义:只认 `==` / `!=` / `&&` / `||` / 括号 / 字符串 /
`needs.<job>.outputs.<名>` / `true|false|null` / `always()|success()|failure()|cancelled()`(后四个按
「本次假定上游成功」取常量)。其余一律认不出 ⇒ 红。`!` 不在支持集里(GitHub 与 Python 的 `!` 优先级
不同,宁可认不出)。
- 只有 `detect` 那一步是**功能断言**;`vectors` / `exitcodes` 两步只做结构断言(它们的命令在 CI 里作为
独立步骤真跑,本脚本不重复跑,免得本地没有 `python3` 时把自检本身搞红)。
- 真 Actions 里 `if:` 求值、job skip 的呈现、required status check 与 skipped 的交互,本脚本一概不管。
变异数据在 `packaging/tests/gpu_shot_ci_vectors.json`,**按解析后的结构注入**(`step_delete` /
`job_delete` / `step_set` / `job_set` / `path_set` / `trigger_set` / `run_append` / `run_replace`,
`also` 可叠加),每条都必须让对应判据当场红、基线必须全绿。类别与来源:
1. 步骤被删 / 被 `if:` 挡住 / 被 `continue-on-error` 兜住(r1 M7/M9/M10/M13/M15/M16/M17/M18、r2 N15/N18);
2. 自测步指向别的脚本、或写成能被 `|| echo` 关掉的多行/带运算符命令(r2 N40/N41/N23);
3. 判定的门被写成恒真(`|| always()`、`if: true`、step 级 `if: ${{ always() }}`、删门)(r1 M3/M19、r2 N4/N5);
4. 判定的产物路径被写死或调用形态被换掉(r1 M20、r2 N9);
5. 判据步的退出码被吞 / 分支没走到(r1 M4、r2 N22/N24/N25/N26/N8'—— 都用真 bash 实跑判据步验证);
6. `detect` 的结论被改坏(`available` 恒 false、跳过不留痕、删 `$GITHUB_OUTPUT`)(r1 M11/M12/M5/M6/M14、r2 N6/N7/N20);
7. `probe.outputs.*` 被改指向别的 step 输出或字面量(**r2 N28/N29**);
8. `on:` 触发面被削(删 `push`/`pull_request`/`workflow_call`)(**r2 N11/N12**);
9. 排版变动**不误红**(加注释/空行基线仍绿)、变异锚点坏掉**报「向量失效」并红**(r2 N16/N17);
10. `if:` 求值器不接受近似写法(`!=`、`!(…)`、`fromJSON`、未知标识符一律红)(r2 N1/N2/N3)。
## 明确**不覆盖**(边界,逐条给理由)
- **改判据脚本本身**:能改这个文件的人也能改判据,它不自守。它只防「顺手改绿」,不防恶意改绿。
- **载体依赖**:把整个 `gpu-evidence.yml` 删掉/换成别的文件,就没有任何东西会跑这个自检 —— 守门人住在
被守对象自己的文件里。`guards.yml` 已经 399/400 行(`file-size` 那道门就扫 `*.yml`),塞不进去。
- **`if:` 求值器与 GitHub 语义的差异**:只认 `==`/`!=`/`&&`/`||`/括号/字符串/`needs.<job>.outputs.<名>`/
`true|false|null`/`always()|success()|failure()|cancelled()`(后四者取常量);其余认不出就是红。
- **判据 4 的「合法调用替换」**:把正例分支换成另一种**合法**调用(`"$PNG" --expect-refuse`)抓不到;
后果是假红不是假绿,判据 5 也会把它判红(正例跑成非 0)。
- **`exitcodes` 那一步的断言被削弱**(例如末尾加 `|| echo`):它是**见证步**,不是门;门的绿由判据 5/6
的功能断言守住,见证步被削只影响「退出码语义也被验过」这条证据。
- **判据 6 的 7 种输入之外的分支**(例如 `detect` 里新增的第五个分支)。
- **真 Actions 的行为**:`if:` 的真求值、job skip 的呈现、required status check 与 skipped 的交互,
本脚本一概不管(`if:` 语义是本脚本的小求值器模拟的)。
## 自我定位与止步线
本文件**不宣称**「不可能被改成总是绿」,只宣称「上面那张封闭清单(10 类 / 33 条向量)会被抓」。
**这条门禁自 2026-11-11 起停止加固**:再往下就会无限递归(守卫守守卫守守卫),边际收益已很低。
后续若要扩覆盖面,必须由**人类新开 mission**,不要在同一轮里继续加判据。
python3 packaging/tests/gpu_shot_ci_selftest.py
"""
@@ -48,7 +79,8 @@ VECTORS_PATH = Path(__file__).resolve().parent / "gpu_shot_ci_vectors.json"
sys.path.insert(0, str(PACKAGING))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from check_workflows_yaml import load_yaml # noqa: E402
from gpu_shot_ci_probe import probe_problems # noqa: E402
from gpu_shot_ci_expr import ConditionError, evaluate_condition # noqa: E402
from gpu_shot_ci_probe import judge_problems, probe_problems # noqa: E402
#: 必须在场、且必须无条件的步骤(按 `id:` 认,不按步骤名认)。
REQUIRED_STEPS = {
@@ -59,15 +91,11 @@ REQUIRED_STEPS = {
"judge": "真实产物判据(evidence 的步骤)",
}
UNCONDITIONAL = ("vectors", "exitcodes", "wiring", "detect")
AVAILABLE_IDENT = "needs.probe.outputs.available"
CALL_FUNCS = {"always": True, "success": True, "failure": False, "cancelled": False}
PROBE_OUTPUT_KEYS = ("available", "log", "png")
SINGLE_COMMAND_STEPS = ("vectors", "wiring")
SHELL_OPERATOR = re.compile(r"[|&;`#$()<>\n]")
SWALLOW_MARKS = ("|| true", "; true", "|| exit 0", "exit 0")
PYTHON_RE = re.compile(r"\bpython3?\b")
_TOKEN_RE = re.compile(r"(?P<string>'[^']*')|(?P<number>\d+)|(?P<ident>[A-Za-z_][\w.\-]*)|(?P<op>==|!=|&&|\|\||[()!])")
class ConditionError(Exception):
"""`if:` 表达式认不出 —— 认不出即判红,绝不默认放过。"""
def coerce_flag(value) -> bool:
@@ -81,46 +109,6 @@ def coerce_flag(value) -> bool:
return str(value).strip().lower() not in {"false", "${{ false }}", "0", "no", "off", ""}
def evaluate_condition(raw, available: str) -> bool:
"""把 GitHub 的 `if:` 表达式求值。只认一小撮语法,其余抛 `ConditionError`(= 判红)。"""
text = str(raw).strip()
if text.startswith("${{") and text.endswith("}}"):
text = text[3:-2].strip()
for name, value in CALL_FUNCS.items():
text = re.sub(rf"\b{name}\s*\(\s*\)", "true" if value else "false", text)
if re.search(r"\b(" + "|".join(CALL_FUNCS) + r")\s*\(", text):
raise ConditionError("函数只支持不带参数的形式")
parts: list[str] = []
pos = 0
while pos < len(text):
if text[pos].isspace():
pos += 1
continue
match = _TOKEN_RE.match(text, pos)
if not match:
raise ConditionError(f"认不出的字符 {text[pos]!r}")
pos = match.end()
token = match.group()
if match.lastgroup in ("string", "number"):
parts.append(token)
elif match.lastgroup == "ident":
if token == AVAILABLE_IDENT:
parts.append("__available")
elif token in ("true", "false", "null"):
parts.append({"true": "True", "false": "False", "null": "None"}[token])
else:
raise ConditionError(f"认不出的标识符 `{token}`")
else:
if token == "!":
raise ConditionError("不支持 `!`(GitHub 与 Python 的优先级不同,宁可认不出)")
parts.append({"&&": "and", "||": "or"}.get(token, token))
source = " ".join(parts)
try:
return bool(eval(source, {"__builtins__": {}}, {"__available": available})) # noqa: S307 - 词表是白名单,见上
except Exception as error: # noqa: BLE001 - 任何求值失败都按「认不出」处理
raise ConditionError(f"求值失败({error}):`{source}`") from None
def steps(doc: dict) -> list[tuple[str, dict, dict]]:
found: list[tuple[str, dict, dict]] = []
for job_id, job in (doc.get("jobs") or {}).items():
@@ -135,6 +123,11 @@ def step_by_id(doc: dict, sid: str) -> tuple[str, dict, dict] | None:
return found[0] if len(found) == 1 else None
def run_of(doc: dict, sid: str) -> str:
entry = step_by_id(doc, sid)
return (entry[2].get("run") or "") if entry else ""
def check_presence(doc: dict) -> list[str]:
problems: list[str] = []
counts = {sid: len([e for e in steps(doc) if e[2].get("id") == sid]) for sid in REQUIRED_STEPS}
@@ -145,12 +138,27 @@ def check_presence(doc: dict) -> list[str]:
job_id, job, step = step_by_id(doc, sid)
if sid in UNCONDITIONAL and ("if" in step or "if" in job):
problems.append(f"`id: {sid}`({label})带 `if:`(步骤或 job 级)—— 这四步必须无条件跑,否则门禁可被静默关掉")
entry = step_by_id(doc, "exitcodes")
if entry:
asserted = set(re.findall(r"\bexpect\s+([0-9]+)\b", entry[2].get("run") or ""))
missing = {"0", "1", "2"} - asserted
if missing:
problems.append(f"`id: exitcodes` 没有断言退出码 {'/'.join(sorted(missing))}(`expect <码>`)—— 三个码就是门禁的接口")
asserted = set(re.findall(r"\bexpect\s+([0-9]+)\b", run_of(doc, "exitcodes")))
missing = {"0", "1", "2"} - asserted
if step_by_id(doc, "exitcodes") and missing:
problems.append(f"`id: exitcodes` 没有断言退出码 {'/'.join(sorted(missing))}(`expect <码>`)—— 三个码就是门禁的接口")
return problems
def check_selftest_commands(doc: dict) -> list[str]:
problems: list[str] = []
wiring = run_of(doc, "wiring").strip()
if wiring and "gpu_shot_ci_selftest.py" not in wiring:
problems.append("`id: wiring` 的 `run` 里没有 `gpu_shot_ci_selftest.py` —— 它可能指向别的脚本(自检根本没跑)")
for sid in SINGLE_COMMAND_STEPS:
run = run_of(doc, sid).strip()
if not run:
continue
if SHELL_OPERATOR.search(run):
problems.append(
f"`id: {sid}` 的 `run` 必须是**单条命令**(不许换行 / `|` / `&` / `;` / 反引号 / `#` / `$(`)"
f"—— 否则红了之后一行 `|| echo 先忽略` 就能把它关掉:`{run}`"
)
return problems
@@ -186,8 +194,6 @@ def check_judge_invocation(doc: dict) -> list[str]:
run = entry[2].get("run") or ""
env = entry[2].get("env") or {}
problems: list[str] = []
# 两种调用形态都必须在场:正例(日志 + PNG)与负例(--expect-refuse,PNG 可选)。
# 少一种 ⇒ 那个分支可以「什么都没判」就绿(r1 复审 M20:把正例那一行换成 `true`,旧判据照样绿)。
for form in ('check_gpu_shot.py "$LOG" "$PNG"', 'check_gpu_shot.py "$LOG" --expect-refuse'):
if form not in run:
problems.append(f"`id: judge` 的 `run` 里缺调用形态 `{form}` —— 该分支可以「什么都没判」就绿")
@@ -197,11 +203,36 @@ def check_judge_invocation(doc: dict) -> list[str]:
return problems
def check_skip_path(doc: dict) -> list[str]:
entry = step_by_id(doc, "detect")
if entry is None:
return [] # presence 已经报过
return probe_problems(entry[2].get("run") or "")
def check_probe_outputs(doc: dict) -> list[str]:
outputs = (doc.get("jobs") or {}).get("probe", {}).get("outputs") or {}
problems: list[str] = []
for key in PROBE_OUTPUT_KEYS:
want = "${{ steps.detect.outputs.%s }}" % key
got = str(outputs.get(key, "")).strip()
if got != want:
problems.append(
f"`probe.outputs.{key}` 必须恰好是 `{want}`,实际 {got!r} —— "
"改指向(`reason` / 字面量 `'false'`)会让门永久关掉,而每次运行都绿"
)
return problems
def check_triggers(doc: dict) -> list[str]:
on_block = doc.get("on", doc.get(True))
if not isinstance(on_block, dict):
return ["`on:` 不是一个映射 —— 触发面认不出(认不出即红)"]
problems: list[str] = []
if "push" not in on_block:
problems.append("`on:` 少了 `push` —— main 上的门禁会停摆")
else:
branches = (on_block.get("push") or {}).get("branches") if isinstance(on_block.get("push"), dict) else None
if branches is not None and "main" not in [str(branch) for branch in branches]:
problems.append("`on.push.branches` 里没有 `main` —— main 上的门禁会停摆")
if "pull_request" not in on_block:
problems.append("`on:` 少了 `pull_request` —— PR 上的门禁会停摆")
if "workflow_call" not in on_block:
problems.append("`on:` 少了 `workflow_call` —— `dev-guards.yml` 的 `uses:` 会加载失败")
return problems
def check_continue_on_error(doc: dict) -> list[str]:
@@ -229,11 +260,15 @@ def check_no_swallow(doc: dict) -> list[str]:
CHECKS = {
"1 五步必须在场": check_presence,
"2 判定必须挂门": check_judge_gate,
"3 判定用探测给的产物": check_judge_invocation,
"4 跳过路径真跑": check_skip_path,
"5 禁 continue-on-error": check_continue_on_error,
"6 不吞退出码": check_no_swallow,
"2 自测步跑的是对脚本且是单条命令": check_selftest_commands,
"3 判定必须挂门": check_judge_gate,
"4 判定用探测给的产物": check_judge_invocation,
"5 判据步真跑(正例 0 / CPU 档非 0)": lambda doc: judge_problems(run_of(doc, "judge")),
"6 跳过路径真跑(7 种输入)": lambda doc: probe_problems(run_of(doc, "detect")),
"7 禁 continue-on-error": check_continue_on_error,
"8 不吞退出码(标记表,兜底)": check_no_swallow,
"9 probe.outputs 绑定 detect": check_probe_outputs,
"10 触发面(push/pull_request/workflow_call)": check_triggers,
}
@@ -243,10 +278,18 @@ def evaluate(doc: dict) -> dict[str, list[str]]:
return {name: check(doc) for name, check in CHECKS.items()}
def _walk(doc: dict, path: list) -> tuple[dict, object]:
node = doc
for key in path[:-1]:
node = node[key]
return node, path[-1]
def apply_vector(doc: dict, vector: dict) -> dict:
"""按**解析后的结构**注入变异(不碰文本格式,改动 workflow 的排版不会让向量过期)。"""
op = vector["op"]
target = str(vector.get("target", ""))
on_key = "on" if "on" in doc else True
if op in ("step_delete", "step_set", "run_append", "run_replace"):
entry = step_by_id(doc, target)
if entry is None:
@@ -268,15 +311,24 @@ def apply_vector(doc: dict, vector: dict) -> dict:
if pos < 0:
raise KeyError(f"`id: {target}` 的 run 里找不到第 {occurrence} 处 `{find}`")
step["run"] = text[:pos] + replacement + text[pos + len(find) :]
elif op == "job_delete" or op == "job_set":
elif op in ("job_delete", "job_set"):
if target not in (doc.get("jobs") or {}):
raise KeyError(f"没有 job `{target}`")
if op == "job_delete":
del doc["jobs"][target]
else:
doc["jobs"][target][vector["key"]] = vector["value"]
elif op == "path_set":
parent, key = _walk(doc, vector["path"])
if key not in parent:
raise KeyError(f"路径不存在:{vector['path']}")
parent[key] = vector["value"]
elif op == "trigger_set":
doc[on_key] = vector["value"]
else:
raise KeyError(f"不认识的变异操作 `{op}`")
for extra in vector.get("also", []):
apply_vector(doc, extra)
return doc
@@ -316,7 +368,8 @@ def main() -> int:
print(f"FAIL:{failures} 处没过({WORKFLOW.name})", file=sys.stderr)
return 1
print(f"ok: {len(CHECKS)} 条接线判据全过,{len(vectors)} 条变异向量全部被抓({WORKFLOW.name})")
print(" 边界:本脚本防「顺手改绿」,不防「连判据一起改」;`if:` 语义由本脚本的小求值器模拟,不是 GitHub 本身。")
print(" 封闭清单 10 类(见 docstring);不覆盖:改判据脚本 / 删掉整个 workflow(载体依赖)/ 求值器语义差 / 合法调用替换。")
print(" 本门禁自 2026-11-11 起停止加固,扩项须由人类新开 mission。")
return 0
+28 -16
View File
@@ -1,26 +1,38 @@
{
"_note": "M253 接线自检的变异向量(r1 复审那 19 条 + 1 条本轮的)。每条按解析后的结构注入,不靠文本排版:op ∈ step_delete / job_delete / step_set / job_set / run_append / run_replace;target 是步骤或 job 的 id。expect = 必须报错的判据名(与 gpu_shot_ci_selftest.py 的 CHECKS 键逐字一致)。",
"_note": "M253 接线自检的变异向量。全部按解析后的结构注入(排版变动不影响):op ∈ step_delete / job_delete / step_set / job_set / path_set / trigger_set / run_append / run_replace;`also` 可叠加一组后续注入。expect = 必须报错的判据名(与 gpu_shot_ci_selftest.py 的 CHECKS 键逐字一致)。计数:33 = r1 复审表 20 条 + 1 条补充(M20)+ r2 复审 12 条(N28/N29/N22/N23/N24/N25/N26/N8'/N40/N41/N11/N12)。",
"vectors": [
{"id": "M1", "label": "自测步命令后加 || true(自测被吞)", "op": "run_append", "target": "vectors", "value": " || true", "expect": "6 不吞退出码"},
{"id": "M2", "label": "接线自检步命令后加 || true(守卫守不到自己)", "op": "run_append", "target": "wiring", "value": " || true", "expect": "6 不吞退出码"},
{"id": "M2b", "label": "接线自检步命令后加 ; true", "op": "run_append", "target": "wiring", "value": "; true", "expect": "6 不吞退出码"},
{"id": "M3", "label": "判定的门加 || always()(门形同虚设)", "op": "job_set", "target": "evidence", "key": "if", "value": "needs.probe.outputs.available == 'true' || always()", "expect": "2 判定必须挂门"},
{"id": "M4", "label": "判据命令后加裸 exit 0", "op": "run_append", "target": "judge", "value": "\nexit 0\n", "expect": "6 不吞退出码"},
{"id": "M5", "label": "整行删掉 ::warning:: 告警", "op": "run_replace", "target": "detect", "find": "echo \"::warning::GPU 证据:跳过(${reason})—— 本次运行没有判过任何真实产物,不构成「GPU 证据已验证」\"", "replace": "", "expect": "4 跳过路径真跑"},
{"id": "M6", "label": "不再写 $GITHUB_OUTPUT(判定永远拿不到产物)", "op": "run_replace", "target": "detect", "find": ">> \"$GITHUB_OUTPUT\"", "replace": ">> /dev/null", "expect": "4 跳过路径真跑"},
{"id": "M1", "label": "自测步命令后加 || true(自测被吞)", "op": "run_append", "target": "vectors", "value": " || true", "expect": "8 不吞退出码(标记表,兜底)"},
{"id": "M2", "label": "接线自检步命令后加 || true(守卫守不到自己)", "op": "run_append", "target": "wiring", "value": " || true", "expect": "8 不吞退出码(标记表,兜底)"},
{"id": "M2b", "label": "接线自检步命令后加 ; true", "op": "run_append", "target": "wiring", "value": "; true", "expect": "2 自测步跑的是对脚本且是单条命令"},
{"id": "M3", "label": "判定的门加 || always()(门形同虚设)", "op": "job_set", "target": "evidence", "key": "if", "value": "needs.probe.outputs.available == 'true' || always()", "expect": "3 判定必须挂门"},
{"id": "M4", "label": "判据命令后加裸 exit 0", "op": "run_append", "target": "judge", "value": "\nexit 0\n", "expect": "8 不吞退出码(标记表,兜底)"},
{"id": "M5", "label": "整行删掉 ::warning:: 告警", "op": "run_replace", "target": "detect", "find": "echo \"::warning::GPU 证据:跳过(${reason})—— 本次运行没有判过任何真实产物,不构成「GPU 证据已验证」\"", "replace": "", "expect": "6 跳过路径真跑(7 种输入)"},
{"id": "M6", "label": "不再写 $GITHUB_OUTPUT(判定永远拿不到产物)", "op": "run_replace", "target": "detect", "find": ">> \"$GITHUB_OUTPUT\"", "replace": ">> /dev/null", "expect": "6 跳过路径真跑(7 种输入)"},
{"id": "M7", "label": "selftest job 加 job 级 if: false", "op": "job_set", "target": "selftest", "key": "if", "value": false, "expect": "1 五步必须在场"},
{"id": "M8", "label": "探测步加 continue-on-error: true", "op": "step_set", "target": "detect", "key": "continue-on-error", "value": true, "expect": "5 禁 continue-on-error"},
{"id": "M8", "label": "探测步加 continue-on-error: true", "op": "step_set", "target": "detect", "key": "continue-on-error", "value": true, "expect": "7 禁 continue-on-error"},
{"id": "M9", "label": "接线自检步加 if: false", "op": "step_set", "target": "wiring", "key": "if", "value": false, "expect": "1 五步必须在场"},
{"id": "M10", "label": "整步删掉接线自检", "op": "step_delete", "target": "wiring", "expect": "1 五步必须在场"},
{"id": "M11", "label": "dispatch 分支 available 恒 false(门禁永久关闭)", "op": "run_replace", "target": "detect", "find": "available=true", "replace": "available=false", "occurrence": 1, "expect": "4 跳过路径真跑"},
{"id": "M12", "label": "约定产物分支 available 恒 false(门禁永久关闭)", "op": "run_replace", "target": "detect", "find": "available=true", "replace": "available=false", "occurrence": 2, "expect": "4 跳过路径真跑"},
{"id": "M11", "label": "dispatch 分支 available 恒 false(门禁永久关闭)", "op": "run_replace", "target": "detect", "find": "available=true", "replace": "available=false", "occurrence": 1, "expect": "6 跳过路径真跑(7 种输入)"},
{"id": "M12", "label": "约定产物分支 available 恒 false(门禁永久关闭)", "op": "run_replace", "target": "detect", "find": "available=true", "replace": "available=false", "occurrence": 2, "expect": "6 跳过路径真跑(7 种输入)"},
{"id": "M13", "label": "整块删掉 evidence job", "op": "job_delete", "target": "evidence", "expect": "1 五步必须在场"},
{"id": "M14", "label": "::warning:: 改成裸 echo(跳过变静默 pass)", "op": "run_replace", "target": "detect", "find": "::warning::", "replace": "", "expect": "4 跳过路径真跑"},
{"id": "M14", "label": "::warning:: 改成裸 echo(跳过变静默 pass)", "op": "run_replace", "target": "detect", "find": "::warning::", "replace": "", "expect": "6 跳过路径真跑(7 种输入)"},
{"id": "M15", "label": "整步删掉 --self-test", "op": "step_delete", "target": "vectors", "expect": "1 五步必须在场"},
{"id": "M16", "label": "整步删掉退出码语义 0/1/2", "op": "step_delete", "target": "exitcodes", "expect": "1 五步必须在场"},
{"id": "M17", "label": "continue-on-error: 'true'(带引号)", "op": "step_set", "target": "detect", "key": "continue-on-error", "value": "true", "expect": "5 禁 continue-on-error"},
{"id": "M18", "label": "continue-on-error: ${{ true }}(表达式)", "op": "step_set", "target": "detect", "key": "continue-on-error", "value": "${{ true }}", "expect": "5 禁 continue-on-error"},
{"id": "M19", "label": "判定步加 if: ${{ always() }}", "op": "step_set", "target": "judge", "key": "if", "value": "${{ always() }}", "expect": "2 判定必须挂门"},
{"id": "M20", "label": "判定步的命令换成 true(不再调判据脚本)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "true", "expect": "3 判定用探测给的产物"}
{"id": "M17", "label": "continue-on-error: 'true'(带引号)", "op": "step_set", "target": "detect", "key": "continue-on-error", "value": "true", "expect": "7 禁 continue-on-error"},
{"id": "M18", "label": "continue-on-error: ${{ true }}(表达式)", "op": "step_set", "target": "detect", "key": "continue-on-error", "value": "${{ true }}", "expect": "7 禁 continue-on-error"},
{"id": "M19", "label": "判定步加 if: ${{ always() }}", "op": "step_set", "target": "judge", "key": "if", "value": "${{ always() }}", "expect": "3 判定必须挂门"},
{"id": "M20", "label": "判定步的正例调用换成 true(不再调判据脚本)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "true", "expect": "4 判定用探测给的产物"},
{"id": "N28", "label": "probe.outputs.available 改指向 reason(门永久关、每次绿)", "op": "path_set", "path": ["jobs", "probe", "outputs", "available"], "value": "${{ steps.detect.outputs.reason }}", "expect": "9 probe.outputs 绑定 detect"},
{"id": "N29", "label": "probe.outputs.available 改成字面量 'false'(门永久关)", "op": "path_set", "path": ["jobs", "probe", "outputs", "available"], "value": "false", "expect": "9 probe.outputs 绑定 detect"},
{"id": "N22", "label": "判据命令后加 || echo 先忽略(绿而没判)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\" || echo \"判据没过(先忽略)\"", "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
{"id": "N24", "label": "判据命令后加 ; :(吞退出码)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\" ; :", "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
{"id": "N25", "label": "去掉 pipefail 再把判据命令接管道 | cat(吞退出码)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\" | cat", "also": [{"op": "run_replace", "target": "judge", "find": "set -uo pipefail", "replace": "set -u"}], "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
{"id": "N26", "label": "判据命令包进 if ! … then echo; fi(吞退出码)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "if ! python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"; then echo \"没判过\"; fi", "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
{"id": "N8b", "label": "判据命令改成 true 后注释掉原行(保留子串锚点)", "op": "run_replace", "target": "judge", "find": "python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "replace": "true # python3 packaging/check_gpu_shot.py \"$LOG\" \"$PNG\"", "expect": "5 判据步真跑(正例 0 / CPU 档非 0)"},
{"id": "N40", "label": "接线自检步指向 gpu_shot_ci_probe.py(自检根本不跑)", "op": "step_set", "target": "wiring", "key": "run", "value": "python3 packaging/tests/gpu_shot_ci_probe.py", "expect": "2 自测步跑的是对脚本且是单条命令"},
{"id": "N41", "label": "接线自检步指向另一个自测(自检根本不跑)", "op": "step_set", "target": "wiring", "key": "run", "value": "python3 packaging/check_gpu_shot.py --self-test", "expect": "2 自测步跑的是对脚本且是单条命令"},
{"id": "N23", "label": "接线自检步后加 || echo 先忽略(守卫把自己关了)", "op": "run_append", "target": "wiring", "value": " || echo \"自检没过(先忽略)\"", "expect": "2 自测步跑的是对脚本且是单条命令"},
{"id": "N11", "label": "删掉 push / pull_request 触发(main、PR 侧停摆)", "op": "trigger_set", "value": ["workflow_dispatch"], "expect": "10 触发面(push/pull_request/workflow_call)"},
{"id": "N12", "label": "删掉 workflow_call 触发(dev 线 uses 会加载失败)", "op": "trigger_set", "value": {"push": {"branches": ["main"]}, "pull_request": null, "workflow_dispatch": null}, "expect": "10 触发面(push/pull_request/workflow_call)"}
]
}