From 075769c6d219c4f19dc26e1aae82a65740fff41a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 17:53:53 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(ci)+test(packaging):=20GPU=20=E8=AF=81?= =?UTF-8?q?=E6=8D=AE=E6=8E=A5=E8=BF=9B=20CI=EF=BC=9A=E8=87=AA=E6=B5=8B?= =?UTF-8?q?=E6=97=A0=E6=9D=A1=E4=BB=B6=E8=B7=91=EF=BC=8C=E6=97=A0=20GPU=20?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E8=B7=B3=E8=BF=87=EF=BC=88=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E2=89=A0=E9=80=9A=E8=BF=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 `.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` 索引更新。 --- .github/workflows/gpu-evidence.yml | 214 ++++++++++++++++++++++ packaging/README.md | 4 +- packaging/docs/gpu-evidence.md | 62 ++++++- packaging/tests/gpu_shot_ci_selftest.py | 226 ++++++++++++++++++++++++ 4 files changed, 503 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/gpu-evidence.yml create mode 100644 packaging/tests/gpu_shot_ci_selftest.py diff --git a/.github/workflows/gpu-evidence.yml b/.github/workflows/gpu-evidence.yml new file mode 100644 index 0000000..7ee1606 --- /dev/null +++ b/.github/workflows/gpu-evidence.yml @@ -0,0 +1,214 @@ +name: GPU evidence + +# GPU 截图证据门禁(M253:把 M249 的 `packaging/check_gpu_shot.py` 接进 CI)。 +# +# **为什么新开一个文件,不并进 guards.yml**:guards.yml 已是 399 行、红线 400(`file-size` 那道门 +# 就扫 `*.yml`),挂一个 job 必越线;`release-build.yml` 正好 400 行、动它得先拆。这与 +# `release-guard.yml` 当初单独成文的理由逐字相同(那份文件头:「那份文件已经 398 行(400 行红线), +# 再挂一个 job 必越线」)。 +# +# 口径(塔定,M253 Context 原文):**CI runner 无 GPU 时必须显式 skip 并写明原因,不得静默 pass**。 +# 落到这份文件的三条硬规矩 —— 它们由 `packaging/tests/gpu_shot_ci_selftest.py` 机器校验 +# (带变异向量),不是靠这几行注释自律: +# ① `selftest` job **无条件**跑 `--self-test`:它证明「尺子合格」,不需要 GPU、也不需要真产物; +# 同一 job 再用真 CLI 走一遍三个退出码(0 过 / 1 判据不过 / 2 用法错)。 +# ② 真实产物判据只在 `probe` job 探到**工作区里真有可用产物**时才跑。 +# ③ **跳过与通过分开写**:跳过的做法是让 `evidence` job 的 `if:` 不成立 —— 它在 Actions 里显示 +# **灰色 skipped**,不是一个绿色的 ✓;同时 `probe` 在 job summary 里写明缺什么、为什么, +# 并打 `::warning::` 说明「本次没有判过任何真实产物」。`evidence` 永远不会在没判过时给绿。 +# +# 判据本身(六条 + `--expect-refuse` 三条)与能证/不能证:`packaging/docs/gpu-evidence.md`。 +# 改这份文件先跑 `python3 packaging/tests/gpu_shot_ci_selftest.py`。 + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + inputs: + shot_log: + description: 'stderr 日志路径(需带 ULL_GPU_LOG=info,例 DevDocs/artifacts/gpu-probe/a1-gpu.stderr.log);留空则按下面的约定路径判' + required: false + default: '' + shot_png: + description: '同一运行的 PNG 路径;负例(expect_refuse=true)可留空' + required: false + default: '' + expect_refuse: + description: '按负例口径判(--expect-refuse:要求 via=refuse 且一张图都没出)' + type: boolean + default: false + +jobs: + selftest: + name: GPU 判据自测(不需 GPU;跳过 ≠ 已验证) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: 判据实现自测(19 条向量,数据在 packaging/tests/gpu_shot_vectors.json) + run: python3 packaging/check_gpu_shot.py --self-test + # 自测走的是判据**函数**,退出码是 `main()` 的那一行。这里用真 CLI 把三个码都走一遍: + # 少了这一步,「退出码永远 0」这种回归在 CI 上照样全绿(门禁自己变成假绿)。 + - name: 退出码语义(0 过 / 1 判据不过 / 2 用法错) + run: | + set -uo pipefail + work="$RUNNER_TEMP/gpu-shot-exitcodes" + mkdir -p "$work" + # 用自测脚手架现造一份能过的一对(同一份 synth_log/synth_png,避免第二套手工拼装) + python3 - "$work" <<'PY' + import sys + from pathlib import Path + sys.path[:0] = ["packaging", "packaging/tests"] + from gpu_shot_selftest import SELFTEST_SIZE, synth_log, synth_png + + root = Path(sys.argv[1]) + (root / "pass.stderr.log").write_text(synth_log(size=SELFTEST_SIZE), encoding="utf-8") + (root / "pass.png").write_bytes(synth_png("content", SELFTEST_SIZE)) + (root / "red.stderr.log").write_text(synth_log(renderer="tiny-skia"), encoding="utf-8") + PY + expect() { # expect <期望退出码> <说明> <命令...> + want="$1"; note="$2"; shift 2 + set +e + "$@" > "$work/out.txt" 2>&1 + got=$? + set -e + cat "$work/out.txt" + if [ "$got" -ne "$want" ]; then + echo "::error::退出码语义不对:$note 期望 $want,实际 $got。判据脚本的 0/1/2 是整条门禁的接口,变了就是门禁变了。" + exit 1 + fi + echo "OK: $note → exit=$got" + } + expect 0 "正例(合成日志 + 合成 PNG,六条判据全过)" \ + python3 packaging/check_gpu_shot.py "$work/pass.stderr.log" "$work/pass.png" + expect 1 "CPU 档日志(renderer=tiny-skia,判据点名判红)" \ + python3 packaging/check_gpu_shot.py "$work/red.stderr.log" "$work/pass.png" + expect 2 "用法错(一个位置参数都没给)" \ + python3 packaging/check_gpu_shot.py + - name: 接线自检(本 workflow 不许被改成「总是绿」) + run: python3 packaging/tests/gpu_shot_ci_selftest.py + + probe: + name: GPU 证据 — 探测可用产物与硬件适配器 + runs-on: ubuntu-latest + outputs: + available: ${{ steps.detect.outputs.available }} + log: ${{ steps.detect.outputs.log }} + png: ${{ steps.detect.outputs.png }} + reason: ${{ steps.detect.outputs.reason }} + steps: + - uses: actions/checkout@v4 + - name: 探测样例产物与硬件适配器(只探测、不判定) + id: detect + env: + SHOT_LOG: ${{ github.event.inputs.shot_log }} + SHOT_PNG: ${{ github.event.inputs.shot_png }} + EXPECT_REFUSE: ${{ github.event.inputs.expect_refuse }} + EVENT: ${{ github.event_name }} + run: | + set -uo pipefail + default_dir=DevDocs/artifacts/gpu-probe + default_stem=gpu-shot + log_path="" + png_path="" + available=false + reason="" + + if [ -n "${SHOT_LOG:-}" ]; then + # ① dispatch 显式点了名:**给了却找不到 = 红**。若「明确说要判、东西不在」能退化成跳过, + # 那又是一次假绿 —— 有意为之的输入缺失是错误,不是 skip。 + if [ ! -f "${SHOT_LOG}" ]; then + echo "::error::dispatch 指定了 shot_log=${SHOT_LOG},但工作区里没这个文件 —— 显式要求判定的输入缺失按红处理(不跳过)" + exit 1 + fi + if [ -n "${SHOT_PNG:-}" ] && [ ! -f "${SHOT_PNG}" ]; then + echo "::error::dispatch 指定了 shot_png=${SHOT_PNG},但工作区里没这个文件 —— 同上,按红处理" + exit 1 + fi + if [ "${EXPECT_REFUSE:-false}" != "true" ] && [ -z "${SHOT_PNG:-}" ]; then + echo "::error::正例(expect_refuse=false)必须给 shot_png;只有负例可以省略 PNG" + exit 1 + fi + log_path="${SHOT_LOG}" + png_path="${SHOT_PNG:-}" + available=true + reason="dispatch 显式指定产物(${log_path})" + elif [ -f "${default_dir}/${default_stem}.stderr.log" ] && [ -f "${default_dir}/${default_stem}.png" ]; then + # ② 约定路径:一次取证 = 一份干净的 stderr + 同一次运行的那张图 + # (`--diff`/拼接会骗过判据,见 gpu-evidence.md §5 第 1/7 条)。 + log_path="${default_dir}/${default_stem}.stderr.log" + png_path="${default_dir}/${default_stem}.png" + available=true + reason="工作区里有约定产物(${default_dir}/${default_stem}.*)" + else + # ③ 什么都没有 —— 这是 GitHub 托管 runner 的常态:托管 runner 没有 GPU, + # 而样例产物也不在仓库里(`DevDocs/` 被 .gitignore 忽略,图不进仓库)。 + # 必须显式跳过 + 写明原因,不许静默 pass。 + if compgen -G '/dev/dri/renderD*' >/dev/null; then + adapter='本机有硬件适配器(/dev/dri/renderD*)' + elif command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null 2>&1; then + adapter='本机有硬件适配器(nvidia-smi -L 有输出)' + else + adapter='无硬件适配器(/dev/dri 下没有 renderD*,也没有可用的 nvidia-smi)' + fi + reason="${adapter},且工作区里没有可用的样例产物(约定路径要求 stderr 日志与同次运行的 PNG 同时存在;DevDocs/ 被 .gitignore 忽略,图不进仓库;event=${EVENT})" + fi + + { + echo "available=${available}" + echo "log=${log_path}" + echo "png=${png_path}" + echo "reason=${reason}" + } >> "$GITHUB_OUTPUT" + + if [ "${available}" = "true" ]; then + echo "GPU 证据:${reason} —— 交给 evidence job 跑真实产物判据(0 过 / 1 判据不过)" + else + echo "GPU 证据:${reason},跳过真实产物判据(判据实现自测已过:那证明「尺子合格」,不证明「这张图是 GPU 出的」)" + # warning 而不是 notice:这一趟运行**看起来是绿的**,必须在 UI 上留一条黄条说清它没判过。 + echo "::warning::GPU 证据:跳过(${reason})—— 本次运行没有判过任何真实产物,不构成「GPU 证据已验证」" + fi + + { + echo "## GPU 证据(M253)" + echo + if [ "${available}" = "true" ]; then + echo "- 结论:**跑真实产物判据**(${reason})" + echo "- 产物:${log_path}" + if [ -n "${png_path}" ]; then echo "- PNG:${png_path}"; else echo "- PNG:无(负例口径 ${EXPECT_REFUSE:-false})"; fi + echo "- 判定看 \`evidence\` job 的退出码(0 过 / 1 判据不过 / 2 用法错);这里是「有东西可判」,不是「判过了」。" + else + echo "- 结论:**⏭ 跳过(未验证)** —— ${reason}" + echo "- 「这张图真是 GPU 档出的」这件事,**本次没有判过**;跳过不等于通过。" + echo "- 已经验过的是尺子本身:\`--self-test\`(19 条向量)与三个退出码 0/1/2(\`selftest\` job)。" + echo "- 想让它变成真判定:\`workflow_dispatch\` 填 \`shot_log\` / \`shot_png\`," + echo " 或在有 GPU 的机器上取证后放到 \`${default_dir}/${default_stem}.stderr.log\` + \`${default_dir}/${default_stem}.png\`。" + fi + } >> "$GITHUB_STEP_SUMMARY" + + evidence: + name: GPU 证据 — 真实产物判据 + needs: [selftest, probe] + # 第 ③ 条规矩就在这一行:没有可用产物 ⇒ 整个 job 不运行(UI 显示 skipped), + # 所以它在「没判过」的情况下不可能显示成一个绿色的 ✓。 + if: needs.probe.outputs.available == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: 判定(0 过 / 1 判据不过 / 2 用法错;退出码即结论,不许被 || true 吞掉) + env: + LOG: ${{ needs.probe.outputs.log }} + PNG: ${{ needs.probe.outputs.png }} + EXPECT_REFUSE: ${{ github.event.inputs.expect_refuse }} + run: | + set -uo pipefail + echo "GPU 证据:判 ${LOG}" + if [ "${EXPECT_REFUSE:-false}" = "true" ]; then + if [ -n "${PNG}" ]; then + python3 packaging/check_gpu_shot.py "$LOG" --expect-refuse "$PNG" + else + python3 packaging/check_gpu_shot.py "$LOG" --expect-refuse + fi + else + python3 packaging/check_gpu_shot.py "$LOG" "$PNG" + fi diff --git a/packaging/README.md b/packaging/README.md index 87638df..5f965d0 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -68,7 +68,9 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 | | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | | `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**,口径与能证/不能证见 `docs/gpu-evidence.md` | ≤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 | ≤400 | +| `packaging/tests/gpu_shot_ci_selftest.py` | 上一条的**接线自检**(M253):5 条判据(自测步不许有 `if:`、真判据必须挂 `outputs.available == 'true'`、跳过那步必须 summary+`跳过`+`::warning::`+`$GITHUB_OUTPUT`、全文件禁 `continue-on-error`、判据命令不许 `|| true`)+ 5 条变异向量(把每种「改绿」写法注入临时副本必须当场红) | ≤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 | diff --git a/packaging/docs/gpu-evidence.md b/packaging/docs/gpu-evidence.md index 004dd96..1bfacc0 100644 --- a/packaging/docs/gpu-evidence.md +++ b/packaging/docs/gpu-evidence.md @@ -7,6 +7,9 @@ 一句话:这套判据把「这张图是 GPU 档出的」从**人读日志**变成**可复跑的退出码**,但它证明的是 「像素由哪条光栅化路径产出」,**不是**「用户看到的窗口长这样」。第 4/5 节逐条划线。 +**CI 接线(M253)在第 8 节**:`--self-test` 无条件跑,真实产物判据只在有可用产物时跑; +**无 GPU 时显式跳过并写明原因,跳过与通过分开写**(跳过 ≠ 通过)。 + ## 1. 怎么跑 ```text @@ -127,9 +130,64 @@ M246 本机实测是 **5.69% / 200**(gallery)与 **2.13% / 75**(shell) ## 7. 没验的部分(M249 自己) -- **没接进 CI**:本脚本目前**只手动跑**,`.github/workflows/**` 一行未动。接 CI 是另一条 mission - (需要 runner 上有真 GPU 或接受「跑不起来就跳过」,是个策略决定,不该顺手改 workflow)。 +- **M249 当时没接进 CI**:那一轮 `.github/workflows/**` 一行未动(接 CI 是策略决定:runner 上有真 GPU, + 还是接受「跑不起来就跳过」)。**M253 已接上**,见 §8;本节其余条目原样保留。 - **没在任何非本机环境跑过**:Windows + 上述单卡 + RDP 断开会话之外,Linux/macOS/其它 GPU 全没跑。 - **没改 Rust**:判据只读现有日志格式。若产品将来改了 `[shot]` / `Selected:` 的行格式,这里的正则 会同时失效(`--self-test` 用的是**按现格式手拼**的合成日志,抓不到格式漂移——它守判据,不守格式)。 - **没验 `--diff` 阈值化**:本文第 4 节引的是 M246 的读数,本 mission 没有重跑 `--diff`。 + +## 8. CI 接线(M253):无 GPU 时显式跳过,**跳过 ≠ 通过** + +判据靠 **`.github/workflows/gpu-evidence.yml`** 跑(`push: main` / `pull_request` / `workflow_dispatch`)。 +**为什么新开一个文件而不是并进 `guards.yml`**:`guards.yml` 已是 399 行、红线 400(`file-size` 那道门 +就扫 `*.yml`),挂一个 job 必越线;`release-build.yml` 正好 400 行、动它得先拆。与 `release-guard.yml` +当初单独成文的理由逐字相同。 + +三个 job,各管一件事: + +| job | 什么时候跑 | 它的结论代表什么 | +|-----|-----------|-----------------| +| `selftest` | **无条件**(不需要 GPU、不需要真产物) | 「尺子合格」:19 条向量 + 三个退出码 0/1/2 用真 CLI 走一遍 | +| `probe` | 无条件,只探测、不判定 | 工作区里有没有可判的产物;没有就把原因写进 job summary 并打 `::warning::` | +| `evidence` | `if: needs.probe.outputs.available == 'true'` | 「这张图真是 GPU 档出的」:`check_gpu_shot.py` 的退出码(0 过 / 1 判据不过 / 2 用法错)即结论 | + +**跳过与通过分开写**:没有可用产物时 `evidence` job 的 `if:` 不成立 ⇒ 它在 Actions 里是**灰色 +skipped**,不是一个绿色 ✓;同时 `probe` 打出(本地手模拟的原样输出,见 §8.1): + +```text +GPU 证据:无硬件适配器(/dev/dri 下没有 renderD*,也没有可用的 nvidia-smi),且工作区里没有可用的样例产物 +(约定路径要求 stderr 日志与同次运行的 PNG 同时存在;DevDocs/ 被 .gitignore 忽略,图不进仓库;event=push), +跳过真实产物判据(判据实现自测已过:那证明「尺子合格」,不证明「这张图是 GPU 出的」) +``` + +外加一条 `::warning::GPU 证据:跳过(…)—— 本次运行没有判过任何真实产物,不构成「GPU 证据已验证」`。 +`available` 同时作为 job output 交给 `evidence`,所以「跳过」与「通过」在数据流上也是两条路。 + +四条路径的口径: + +| 情形 | 结果 | 为什么这么定 | +|------|------|-------------| +| 工作区里有 `DevDocs/artifacts/gpu-probe/gpu-shot.stderr.log` + `gpu-shot.png`(约定成对) | `available=true` ⇒ `evidence` 真判 | 一次取证 = 一份干净的 stderr + 同次运行的那张图(§5 第 1/7 条);**只给一半仍按跳过**(半套产物不算可用) | +| `workflow_dispatch` 填了 `shot_log` / `shot_png` | 同上 | 显式点名 ⇒ 真判 | +| 点了名但**文件不存在**(或正例没给 PNG) | **红**(`::error::` + exit 1) | 「明确说要判、东西不在」若能退化成跳过,就又是一次假绿 —— 有意为之的输入缺失是错误,不是 skip | +| 托管 runner 常态(无 GPU + 无产物) | 跳过 + 原因 + 黄条 | 见上;托管 runner 本来就没有硬件适配器 | + +**接线不许被改成「总是绿」**:`packaging/tests/gpu_shot_ci_selftest.py` 把这条口径变成机器判据 —— +自测步不许有 `if:`、真实判据必须挂在 `outputs.available == 'true'` 上、跳过那步必须同时写 +`$GITHUB_STEP_SUMMARY` + `跳过` + `::warning::` + `$GITHUB_OUTPUT`、全文件不许 `continue-on-error`、 +判据命令不许 `|| true`。它自带 5 条变异向量:把上面每种「改绿」写法注入临时副本,对应判据必须当场红。 + +### 8.1 这一节**没验**的部分 + +- **CI 上从没真跑过**:本 workflow 是本地写的,**一次 GitHub Actions 运行都没有**(未 push)。 + 本地做的是**手模拟**:把三个 step 的 `run:` 原文抽出来用 bash 跑(`GITHUB_OUTPUT` / + `GITHUB_STEP_SUMMARY` 指向临时文件),四条路径(跳过 / 约定产物 / dispatch 产物 / dispatch 缺失即红) + 与 `evidence` 的 0/1/2 都按预期。**这不是 CI 实测**:`if:` 求值、job skip 在 UI 上的呈现、 + `needs` 的输出传递在真 Actions 里仍未跑过。 +- **没在真 GPU runner 上跑过**:`evidence` 的真判定只在本地用合成向量走过(0 与 1 都到达过), + 「真产物 + 真 GPU runner」这个组合没实测。 +- **没验「跳过是否满足 required status check」**:本 workflow 没挂成必需检查;GitHub 对 skipped job + 与 required check 的交互口径没实测,别把这一节读成「跳过也能当门过」。 +- **没接日常线 `dev`**:`dev-guards.yml` 复用的是 `guards.yml`,本文件不在其中;挂上去要多一次 + `uses:`,那是新的成本决定,不在 M253 里顺手做。 diff --git a/packaging/tests/gpu_shot_ci_selftest.py b/packaging/tests/gpu_shot_ci_selftest.py new file mode 100644 index 0000000..6f77d55 --- /dev/null +++ b/packaging/tests/gpu_shot_ci_selftest.py @@ -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..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..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} <日志> `,且日志/图来自 `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()) From 2e9702790d01e5b1621f31e8dfa49bd7d2f3f875 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 18:08:31 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(ci)+test(packaging):=20=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF=E8=87=AA=E6=A3=80=E4=B8=8D=E5=86=8D=E7=BB=99=E5=81=87?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E8=AF=81=E4=B9=A6=EF=BC=9A19=20=E6=9D=A1?= =?UTF-8?q?=E5=8F=98=E5=BC=82=E9=87=8D=E6=94=BE=E5=85=A8=E7=BA=A2=EF=BC=8C?= =?UTF-8?q?dev=20=E7=BA=BF=E6=8E=A5=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 上做不到」。 措辞:三处「不可能被改成总是绿」改成能复跑的覆盖面 + 边界。 --- .github/workflows/dev-guards.yml | 8 + .github/workflows/gpu-evidence.yml | 30 +- packaging/README.md | 8 +- packaging/docs/gpu-evidence.md | 46 ++- packaging/tests/gpu_shot_ci_probe.py | 145 +++++++++ packaging/tests/gpu_shot_ci_selftest.py | 382 ++++++++++++++--------- packaging/tests/gpu_shot_ci_vectors.json | 26 ++ 7 files changed, 483 insertions(+), 162 deletions(-) create mode 100644 packaging/tests/gpu_shot_ci_probe.py create mode 100644 packaging/tests/gpu_shot_ci_vectors.json diff --git a/.github/workflows/dev-guards.yml b/.github/workflows/dev-guards.yml index 33a22ec..be56b68 100644 --- a/.github/workflows/dev-guards.yml +++ b/.github/workflows/dev-guards.yml @@ -49,6 +49,14 @@ jobs: name: 架构与合规守门(同 main 口径) uses: ./.github/workflows/guards.yml + # GPU 证据门禁(M253 r1 P2-1):与 main **同一份** workflow,理由同上 —— 判据只能有一份。 + # dev 上 `evidence` 照样显式跳过(无 GPU、无产物,见该文件的 probe job),所以实际拿到的是 + # `selftest` 那一半:19 条向量 + 三个退出码 0/1/2 + 接线自检,成本就是一次 python 自测。 + # 不另加「只跑自测」的开关:那会给这道门禁多开一个「可以合法关掉真判据」的旋钮。 + gpu-evidence: + name: GPU 证据门禁(同 main 口径;无 GPU 只跑自测) + uses: ./.github/workflows/gpu-evidence.yml + fmt: name: fmt (rustfmt --check) runs-on: ubuntu-latest diff --git a/.github/workflows/gpu-evidence.yml b/.github/workflows/gpu-evidence.yml index 7ee1606..07cee86 100644 --- a/.github/workflows/gpu-evidence.yml +++ b/.github/workflows/gpu-evidence.yml @@ -9,13 +9,14 @@ name: GPU evidence # # 口径(塔定,M253 Context 原文):**CI runner 无 GPU 时必须显式 skip 并写明原因,不得静默 pass**。 # 落到这份文件的三条硬规矩 —— 它们由 `packaging/tests/gpu_shot_ci_selftest.py` 机器校验 -# (带变异向量),不是靠这几行注释自律: +# (21 条「改绿」变异逐条重放),不是靠这几行注释自律;那份脚本的 docstring 写清它**保证什么、 +# 保证不了什么**(r1 复审实测:上一版 19 条变异漏 11 条,所以这里不敢再写「不可能」): # ① `selftest` job **无条件**跑 `--self-test`:它证明「尺子合格」,不需要 GPU、也不需要真产物; # 同一 job 再用真 CLI 走一遍三个退出码(0 过 / 1 判据不过 / 2 用法错)。 # ② 真实产物判据只在 `probe` job 探到**工作区里真有可用产物**时才跑。 # ③ **跳过与通过分开写**:跳过的做法是让 `evidence` job 的 `if:` 不成立 —— 它在 Actions 里显示 # **灰色 skipped**,不是一个绿色的 ✓;同时 `probe` 在 job summary 里写明缺什么、为什么, -# 并打 `::warning::` 说明「本次没有判过任何真实产物」。`evidence` 永远不会在没判过时给绿。 +# 并打 `::warning::` 说明「本次没有判过任何真实产物」。 # # 判据本身(六条 + `--expect-refuse` 三条)与能证/不能证:`packaging/docs/gpu-evidence.md`。 # 改这份文件先跑 `python3 packaging/tests/gpu_shot_ci_selftest.py`。 @@ -38,6 +39,12 @@ on: description: '按负例口径判(--expect-refuse:要求 via=refuse 且一张图都没出)' type: boolean default: false + # 日常线 `dev` 复用**同一份**判据与接线(r1 P2-1):`dev-guards.yml` 里 + # `uses: ./.github/workflows/gpu-evidence.yml`。理由与 guards.yml 的 `workflow_call:` 逐字相同: + # 判据只能有一份,两份各自演进迟早出现「dev 绿、main 红」的双真相;而 `dev-guards.yml` 文件头 + # 自己写着「空跑的门禁等于没有」。dev 上 `evidence` 照样显式跳过(无 GPU、无产物),所以 dev 拿到的 + # 是 `selftest` 那一半:19 条向量 + 三个退出码 + 接线自检,成本就是一次 python 自测。 + workflow_call: jobs: selftest: @@ -46,10 +53,12 @@ jobs: steps: - uses: actions/checkout@v4 - name: 判据实现自测(19 条向量,数据在 packaging/tests/gpu_shot_vectors.json) + id: vectors run: python3 packaging/check_gpu_shot.py --self-test # 自测走的是判据**函数**,退出码是 `main()` 的那一行。这里用真 CLI 把三个码都走一遍: # 少了这一步,「退出码永远 0」这种回归在 CI 上照样全绿(门禁自己变成假绿)。 - name: 退出码语义(0 过 / 1 判据不过 / 2 用法错) + id: exitcodes run: | set -uo pipefail work="$RUNNER_TEMP/gpu-shot-exitcodes" @@ -85,7 +94,8 @@ jobs: python3 packaging/check_gpu_shot.py "$work/red.stderr.log" "$work/pass.png" expect 2 "用法错(一个位置参数都没给)" \ python3 packaging/check_gpu_shot.py - - name: 接线自检(本 workflow 不许被改成「总是绿」) + - name: 接线自检(21 条「改绿」变异必须重放为红;覆盖边界见脚本 docstring) + id: wiring run: python3 packaging/tests/gpu_shot_ci_selftest.py probe: @@ -181,21 +191,27 @@ jobs: echo "- 结论:**⏭ 跳过(未验证)** —— ${reason}" echo "- 「这张图真是 GPU 档出的」这件事,**本次没有判过**;跳过不等于通过。" echo "- 已经验过的是尺子本身:\`--self-test\`(19 条向量)与三个退出码 0/1/2(\`selftest\` job)。" - echo "- 想让它变成真判定:\`workflow_dispatch\` 填 \`shot_log\` / \`shot_png\`," - echo " 或在有 GPU 的机器上取证后放到 \`${default_dir}/${default_stem}.stderr.log\` + \`${default_dir}/${default_stem}.png\`。" + echo "- 想让它变成真判定,只有两条**在这个 CI 运行里真能执行**的路:" + echo " ① \`workflow_dispatch\` 填 \`shot_log\` / \`shot_png\`,且两个文件必须**已被 checkout 下来**" + echo " —— checkout 只拿已跟踪文件,所以产物得先提交进被 dispatch 的那个 ref;" + echo " ② 把一对产物提交到 \`${default_dir}/${default_stem}.stderr.log\` + \`${default_dir}/${default_stem}.png\`" + echo " 再对那个 ref 跑一次(\`${default_dir}\` 在 .gitignore 里,要用 \`git add -f\`;" + echo " 或改放到任一已跟踪目录,用 ① 的输入指过去)。" + echo " 就地往 workspace 里拷文件(不提交)在 CI 上做不到:那次运行 checkout 的树里没有 DevDocs/。" fi } >> "$GITHUB_STEP_SUMMARY" evidence: name: GPU 证据 — 真实产物判据 needs: [selftest, probe] - # 第 ③ 条规矩就在这一行:没有可用产物 ⇒ 整个 job 不运行(UI 显示 skipped), - # 所以它在「没判过」的情况下不可能显示成一个绿色的 ✓。 + # 第 ③ 条规矩就在这一行:没有可用产物 ⇒ 整个 job 不运行,UI 上是灰色 skipped, + # 不是一个绿色 ✓。这正是「跳过」与「通过」分开写的地方(r1 复审实核过这条 if 的语义)。 if: needs.probe.outputs.available == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: 判定(0 过 / 1 判据不过 / 2 用法错;退出码即结论,不许被 || true 吞掉) + id: judge env: LOG: ${{ needs.probe.outputs.log }} PNG: ${{ needs.probe.outputs.png }} diff --git a/packaging/README.md b/packaging/README.md index 5f965d0..5f9760a 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -69,8 +69,10 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | | `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 | ≤400 | -| `packaging/tests/gpu_shot_ci_selftest.py` | 上一条的**接线自检**(M253):5 条判据(自测步不许有 `if:`、真判据必须挂 `outputs.available == 'true'`、跳过那步必须 summary+`跳过`+`::warning::`+`$GITHUB_OUTPUT`、全文件禁 `continue-on-error`、判据命令不许 `|| true`)+ 5 条变异向量(把每种「改绿」写法注入临时副本必须当场红) | ≤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_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 | @@ -80,7 +82,7 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | `packaging/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 | | `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 | | `packaging/docs/linux-evidence.md` | Linux 槽位的证据账(M184):现有证据盘点、WSLg 真机实测输出、xvfb 复跑、CI 增量估算、未验清单 | ≤400 | -| `packaging/docs/gpu-evidence.md` | GPU 截图证据账(M249):判据逐条「抓得住什么/抓不住什么」、本机实跑退出码、**能证**(字形栅格与边缘 AA / 面板 α 与玻璃 / 两后端一致性)与**不能证**(首帧与帧率 / 整窗 α 与圆角观感,走 DWM 任何截图都拍不到)、历史 `--diff` 基线永久不可复核 | ≤400 | +| `packaging/docs/gpu-evidence.md` | GPU 截图证据账(M249 + §8 CI 接线 M253:无 GPU 显式跳过、跳过 ≠ 通过、21 条接线变异):判据逐条「抓得住什么/抓不住什么」、本机实跑退出码、**能证**(字形栅格与边缘 AA / 面板 α 与玻璃 / 两后端一致性)与**不能证**(首帧与帧率 / 整窗 α 与圆角观感,走 DWM 任何截图都拍不到)、历史 `--diff` 基线永久不可复核 | ≤400 | | `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 | | `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 | | `.github/workflows/release.yml` | tag 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 | diff --git a/packaging/docs/gpu-evidence.md b/packaging/docs/gpu-evidence.md index 1bfacc0..44491e7 100644 --- a/packaging/docs/gpu-evidence.md +++ b/packaging/docs/gpu-evidence.md @@ -139,11 +139,19 @@ M246 本机实测是 **5.69% / 200**(gallery)与 **2.13% / 75**(shell) ## 8. CI 接线(M253):无 GPU 时显式跳过,**跳过 ≠ 通过** -判据靠 **`.github/workflows/gpu-evidence.yml`** 跑(`push: main` / `pull_request` / `workflow_dispatch`)。 +判据靠 **`.github/workflows/gpu-evidence.yml`** 跑(`push: main` / `pull_request` / `workflow_dispatch`, +以及 `workflow_call`:日常线 `dev` 经 `dev-guards.yml` 复用同一份,见下)。 **为什么新开一个文件而不是并进 `guards.yml`**:`guards.yml` 已是 399 行、红线 400(`file-size` 那道门 就扫 `*.yml`),挂一个 job 必越线;`release-build.yml` 正好 400 行、动它得先拆。与 `release-guard.yml` 当初单独成文的理由逐字相同。 +**日常线 `dev` 也跑**(M253 r1 P2-1):`dev-guards.yml` 里多一个 +`gpu-evidence: uses: ./.github/workflows/gpu-evidence.yml`。`dev-guards.yml` 文件头自己写着 +「门禁存在但日常线上永不运行 = 空跑的门禁,长期等于没有」,而这条 workflow 原先只在 `main`/PR 触发, +直推 `dev` 时**一次都不跑**。dev 上 `evidence` 照样显式跳过(无 GPU、无产物),所以 dev 拿到的是 +`selftest` 那一半:19 条向量 + 三个退出码 + 接线自检。**刻意不加**「只跑自测」的开关 —— 那会给这道 +门禁多开一个「可以合法关掉真判据」的旋钮。 + 三个 job,各管一件事: | job | 什么时候跑 | 它的结论代表什么 | @@ -173,21 +181,39 @@ GPU 证据:无硬件适配器(/dev/dri 下没有 renderD*,也没有可用 | 点了名但**文件不存在**(或正例没给 PNG) | **红**(`::error::` + exit 1) | 「明确说要判、东西不在」若能退化成跳过,就又是一次假绿 —— 有意为之的输入缺失是错误,不是 skip | | 托管 runner 常态(无 GPU + 无产物) | 跳过 + 原因 + 黄条 | 见上;托管 runner 本来就没有硬件适配器 | -**接线不许被改成「总是绿」**:`packaging/tests/gpu_shot_ci_selftest.py` 把这条口径变成机器判据 —— -自测步不许有 `if:`、真实判据必须挂在 `outputs.available == 'true'` 上、跳过那步必须同时写 -`$GITHUB_STEP_SUMMARY` + `跳过` + `::warning::` + `$GITHUB_OUTPUT`、全文件不许 `continue-on-error`、 -判据命令不许 `|| true`。它自带 5 条变异向量:把上面每种「改绿」写法注入临时副本,对应判据必须当场红。 +**在 CI 里怎么让它真判**(r1 复审 P2-2:原先 §8 说的「把产物放到 `DevDocs/`」在 CI 里没人做得到 —— +**checkout 只拿已跟踪文件**,而 `/DevDocs/` 在 `.gitignore:6` 里,也没有 `upload/download-artifact` 环节): + +1. 把一次取证的那一对产物**提交进你打算 dispatch 的那个 ref**:`git add -f + DevDocs/artifacts/gpu-probe/gpu-shot.stderr.log DevDocs/artifacts/gpu-probe/gpu-shot.png`(`-f` 是必需的, + 否则被 ignore); +2. 对该 ref 跑 `workflow_dispatch`,或干脆让它走 `push` / PR —— `probe` 会认这对约定路径,`available=true`; +3. 不想提交到 `DevDocs/` 的话,把两个文件放到任一**已跟踪**目录,再用 dispatch 的 + `shot_log` / `shot_png` 指过去(那两个输入只认「checkout 后确实存在的路径」)。 + +就地往 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`),不再给绝对化的安全证书。 ### 8.1 这一节**没验**的部分 - **CI 上从没真跑过**:本 workflow 是本地写的,**一次 GitHub Actions 运行都没有**(未 push)。 - 本地做的是**手模拟**:把三个 step 的 `run:` 原文抽出来用 bash 跑(`GITHUB_OUTPUT` / - `GITHUB_STEP_SUMMARY` 指向临时文件),四条路径(跳过 / 约定产物 / dispatch 产物 / dispatch 缺失即红) - 与 `evidence` 的 0/1/2 都按预期。**这不是 CI 实测**:`if:` 求值、job skip 在 UI 上的呈现、 + 本地验的是两件事:① 把三个 step 的 `run:` 原文抽出来用 bash 跑(跳过 / 约定成对 / 约定半套 / + dispatch 成对 / 点名不存在 / 正例缺 PNG / 负例 —— 这 7 条已由判据 4 **每次自检都重跑**); + ② `evidence` 的判据命令本地实跑过 0 与 1。**这不是 CI 实测**:`if:` 求值、job skip 在 UI 上的呈现、 `needs` 的输出传递在真 Actions 里仍未跑过。 - **没在真 GPU runner 上跑过**:`evidence` 的真判定只在本地用合成向量走过(0 与 1 都到达过), 「真产物 + 真 GPU runner」这个组合没实测。 - **没验「跳过是否满足 required status check」**:本 workflow 没挂成必需检查;GitHub 对 skipped job 与 required check 的交互口径没实测,别把这一节读成「跳过也能当门过」。 -- **没接日常线 `dev`**:`dev-guards.yml` 复用的是 `guards.yml`,本文件不在其中;挂上去要多一次 - `uses:`,那是新的成本决定,不在 M253 里顺手做。 +- **自检脚本自身的边界**在它自己的 docstring 里(不防「连判据一起改」;`!` 不在支持的表达式语法里, + 出现即红;只有 `probe` 那一步是功能断言,`vectors`/`exitcodes` 两步只做结构断言)。 diff --git a/packaging/tests/gpu_shot_ci_probe.py b/packaging/tests/gpu_shot_ci_probe.py new file mode 100644 index 0000000..194db00 --- /dev/null +++ b/packaging/tests/gpu_shot_ci_probe.py @@ -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] diff --git a/packaging/tests/gpu_shot_ci_selftest.py b/packaging/tests/gpu_shot_ci_selftest.py index 6f77d55..d5dd36c 100644 --- a/packaging/tests/gpu_shot_ci_selftest.py +++ b/packaging/tests/gpu_shot_ci_selftest.py @@ -1,50 +1,127 @@ #!/usr/bin/env python3 -"""`gpu-evidence.yml` 的接线自检(M253):断言这道门禁**不可能被改成「总是绿」**。 +r"""`gpu-evidence.yml` 的**接线自检**(M253 / r1 复审返工):把「这道门禁怎么才算没被改绿」变成判据。 -为什么需要它:`check_gpu_shot.py --self-test` 只证明「尺子能量」,它管不到**接线**—— -把自测步写成 `if: false`、给真实判据步加 `continue-on-error: true`、或者把命令尾巴写成 -`|| true`,三种改法的结局都一样:job 变绿、一次判据都没真的跑过。本仓已经栽过四次 -「守卫假绿」,所以这里把接线口径变成机器判据,而不是让下一个人读注释自律。 +历史教训(必须留着):r1 那一版宣称「不可能被改成总是绿」,复审独立造了 19 个「改成总是绿」的变异, +**11 个它照样绿** —— 最要命的是把 `available` 写成恒 `false`:门禁永久关闭,而 push/PR 本来就跳过, +**肉眼与机器都看不出**。原因有三:判据 2/3 本质是字符串存在性检查、判据 5 把自测步整条豁免且不扫 +`exit 0`、判据 4 用 `is True` 挡不住 `'true'` / `${{ true }}`。那一版给的是一张**假的安全证书**。 -五条判据(对应 workflow 头部的三条规矩): +现在六条判据,能结构化的不靠字符串、能跑的不靠读文本: -1. **自测无条件跑**:跑 `--self-test` 的那一步、以及它所在的 job,都不许有 `if:`; -2. **真实判据必须挂在门上**:跑真产物判据的步(或它的 job)的 `if:` 必须引用 - `needs..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.*`(不是写死的)。 +| # | 判据 | 怎么判 | 抓的是什么 | +|---|------|--------|-----------| +| 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` | -自带 **5 条变异向量**:把上面每一种「改绿」写法注入临时副本,断言对应判据当场红、基线绿。 -判据自己抓不住回归时它会先红,所以这些断言不会变成装饰品(同 `check_release_semantics.py ---self-mutation` 的做法)。 +再叠一层:`packaging/tests/gpu_shot_ci_vectors.json` 里 **21 条变异向量**(r1 复审那张表里的 20 条 + 一条 +「把判定步的命令换成 `true`」),每条按**解析后的结构**注入(`step_delete` / `job_delete` / `step_set` / `job_set` / +`run_append` / `run_replace`),每条都必须让对应判据当场红、基线必须全绿。 + +**保证不了什么(别读成免责声明,读成边界)**: +- 这些判据本身不是自守的:能改这个文件的人也能改判据。它防的是**顺手改绿**,不是恶意改绿。 +- `if:` 是我自己写的小求值器,不是 GitHub 的语义:只认 `==` / `!=` / `&&` / `||` / 括号 / 字符串 / + `needs..outputs.<名>` / `true|false|null` / `always()|success()|failure()|cancelled()`(后四个按 + 「本次假定上游成功」取常量)。其余一律认不出 ⇒ 红。`!` 不在支持集里(GitHub 与 Python 的 `!` 优先级 + 不同,宁可认不出)。 +- 只有 `detect` 那一步是**功能断言**;`vectors` / `exitcodes` 两步只做结构断言(它们的命令在 CI 里作为 + 独立步骤真跑,本脚本不重复跑,免得本地没有 `python3` 时把自检本身搞红)。 +- 真 Actions 里 `if:` 求值、job skip 的呈现、required status check 与 skipped 的交互,本脚本一概不管。 python3 packaging/tests/gpu_shot_ci_selftest.py - -零第三方依赖,只借 `check_workflows_yaml.load_yaml`(它自己也负责「PyYAML 缺失 ⇒ 门禁不降级」)。 """ from __future__ import annotations +import copy +import json +import re import sys from pathlib import Path PACKAGING = Path(__file__).resolve().parent.parent WORKFLOW = PACKAGING.parent / ".github/workflows/gpu-evidence.yml" +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 -SELFTEST_MARK = "check_gpu_shot.py --self-test" -JUDGE_MARK = "check_gpu_shot.py" -GATE = "outputs.available" -SWALLOW_MARKS = ("|| true", "|| exit 0", "; true") +#: 必须在场、且必须无条件的步骤(按 `id:` 认,不按步骤名认)。 +REQUIRED_STEPS = { + "vectors": "判据实现自测(`--self-test`,19 条向量)", + "exitcodes": "退出码语义(真 CLI 走 0/1/2)", + "wiring": "接线自检(本脚本,必须在场)", + "detect": "产物探测(probe 的唯一步骤)", + "judge": "真实产物判据(evidence 的步骤)", +} +UNCONDITIONAL = ("vectors", "exitcodes", "wiring", "detect") +AVAILABLE_IDENT = "needs.probe.outputs.available" +CALL_FUNCS = {"always": True, "success": True, "failure": False, "cancelled": False} +SWALLOW_MARKS = ("|| true", "; true", "|| exit 0", "exit 0") +PYTHON_RE = re.compile(r"\bpython3?\b") +_TOKEN_RE = re.compile(r"(?P'[^']*')|(?P\d+)|(?P[A-Za-z_][\w.\-]*)|(?P==|!=|&&|\|\||[()!])") + + +class ConditionError(Exception): + """`if:` 表达式认不出 —— 认不出即判红,绝不默认放过。""" + + +def coerce_flag(value) -> bool: + """YAML 里的开关按**真语义**判:`true` / `'true'` / `${{ true }}` 都算开启。""" + if value is True: + return True + if value is False or value is None: + return False + if isinstance(value, (int, float)): + return bool(value) + 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]]: - """`[(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 []: @@ -53,147 +130,164 @@ def steps(doc: dict) -> list[tuple[str, dict, dict]]: return found -def check_selftest_unconditional(doc: dict) -> list[str]: - """判据 1:自测那步不许带任何条件(job 级 or step 级)。""" +def step_by_id(doc: dict, sid: str) -> tuple[str, dict, dict] | None: + found = [entry for entry in steps(doc) if entry[2].get("id") == sid] + return found[0] if len(found) == 1 else None + + +def check_presence(doc: dict) -> list[str]: problems: list[str] = [] - hits = 0 - for job_id, job, step in steps(doc): - if SELFTEST_MARK not in (step.get("run") or ""): + counts = {sid: len([e for e in steps(doc) if e[2].get("id") == sid]) for sid in REQUIRED_STEPS} + for sid, label in REQUIRED_STEPS.items(): + if counts[sid] != 1: + problems.append(f"`id: {sid}`({label})必须恰好一步,实际 {counts[sid]} 步 —— 删掉或复制都算红") 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}`") + 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 <码>`)—— 三个码就是门禁的接口") 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..outputs.available == 'true'` 上。""" +def check_judge_gate(doc: dict) -> list[str]: + entry = step_by_id(doc, "judge") + if entry is None: + return [] # presence 已经报过 + job_id, job, step = entry + gates: list[tuple[str, object]] = [] + if "if" in job: + gates.append((f"job `{job_id}` 的 `if`", job.get("if"))) + if "if" in step: + gates.append(("`id: judge` 步的 `if`", step.get("if"))) + if not gates: + return ["`judge` 步与它的 job 都没有 `if:` —— 无产物时也会照跑,「跳过」与「通过」分不开"] 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} <日志> `,且日志/图来自 `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'` 的门 —— 无产物时也会照跑," - "「跳过」与「通过」就分不开了" - ) + for label, raw in gates: + for available, want in (("true", True), ("false", False), ("", False)): + try: + got = evaluate_condition(raw, available) + except ConditionError as error: + problems.append(f"{label} 认不出({error})—— 认不出即红:`{raw}`") + break + if got is not want: + problems.append(f"{label} 在 `available={available!r}` 时求值为 {got},应为 {want}:`{raw}`") 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` 会把红的判据变绿,全文件禁用。""" +def check_judge_invocation(doc: dict) -> list[str]: + entry = step_by_id(doc, "judge") + if entry is None: + return [] + run = entry[2].get("run") or "" + env = entry[2].get("env") or {} 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` —— 判据红也会变成绿") + # 两种调用形态都必须在场:正例(日志 + 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}` —— 该分支可以「什么都没判」就绿") + for name, ref in (("LOG", "needs.probe.outputs.log"), ("PNG", "needs.probe.outputs.png")): + if ref not in str(env.get(name, "")): + problems.append(f"`judge` 的 `{name}` 不是来自 `{ref}`(写死的路径会在跳过那条路上判错东西)") return problems -def check_no_swallowed_exit_code(doc: dict) -> list[str]: - """判据 5:判据命令不许吞退出码(`|| true` / `; true` 会把红判据变成绿)。""" +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_continue_on_error(doc: dict) -> list[str]: + problems: list[str] = [] + for job_id, job in (doc.get("jobs") or {}).items(): + if coerce_flag((job or {}).get("continue-on-error")): + problems.append(f"job `{job_id}` 带 `continue-on-error: {job.get('continue-on-error')!r}` —— 判据红也会变绿") + for step in (job or {}).get("steps") or []: + if isinstance(step, dict) and coerce_flag(step.get("continue-on-error")): + problems.append(f"job `{job_id}` 的 `{step.get('id') or step.get('name')}` 带 `continue-on-error: {step.get('continue-on-error')!r}`") + return problems + + +def check_no_swallow(doc: dict) -> list[str]: 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: + if not (PYTHON_RE.search(run) and "packaging/" 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}` —— 退出码被吞掉,判据红也绿") + hits = [mark for mark in SWALLOW_MARKS if mark in run] + if hits: + problems.append(f"job `{job_id}` 的 `{step.get('id') or step.get('name')}` 里出现 {'/'.join(f'`{h}`' for h in hits)} —— 退出码被吞掉,判据红也绿") 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, + "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, } -#: 变异向量:`(说明, 原文片段, 替换成, 应该红的判据)`。原文片段找不到 = 向量过期,同样算失败。 -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: +def evaluate(doc: dict) -> dict[str, list[str]]: + if not isinstance(doc, dict) or not isinstance(doc.get("jobs"), dict): return {name: ["workflow 不是一个带 jobs 的 YAML 映射"] for name in CHECKS} return {name: check(doc) for name, check in CHECKS.items()} +def apply_vector(doc: dict, vector: dict) -> dict: + """按**解析后的结构**注入变异(不碰文本格式,改动 workflow 的排版不会让向量过期)。""" + op = vector["op"] + target = str(vector.get("target", "")) + if op in ("step_delete", "step_set", "run_append", "run_replace"): + entry = step_by_id(doc, target) + if entry is None: + raise KeyError(f"找不到唯一的 `id: {target}`") + step = entry[2] + if op == "step_delete": + entry[1]["steps"].remove(step) + elif op == "step_set": + step[vector["key"]] = vector["value"] + else: + text = step.get("run") or "" + if op == "run_append": + step["run"] = text + vector["value"] + else: + find, replacement, occurrence = vector["find"], vector["replace"], int(vector.get("occurrence", 1)) + pos = -1 + for _ in range(occurrence): + pos = text.find(find, pos + 1) + 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": + 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"] + else: + raise KeyError(f"不认识的变异操作 `{op}`") + return doc + + def main() -> int: yaml = load_yaml() if not WORKFLOW.is_file(): - print(f"::error file={WORKFLOW}::接线自检的靶子不存在 —— GPU 证据门禁是不是被删了?", file=sys.stderr) + print(f"::error file={WORKFLOW}::接线自检的靶子不存在", file=sys.stderr) return 1 - text = WORKFLOW.read_text(encoding="utf-8") + doc = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) failures = 0 - - baseline = evaluate(yaml, text) - for name, problems in baseline.items(): + for name, problems in evaluate(doc).items(): if problems: failures += 1 print(f"FAIL: 判据「{name}」没过:", file=sys.stderr) @@ -202,23 +296,27 @@ def main() -> int: else: print(f"OK: 判据「{name}」") - for label, old, new, expected in VECTORS: - if old not in text: + vectors = json.loads(VECTORS_PATH.read_text(encoding="utf-8"))["vectors"] + for vector in vectors: + label = f"{vector['id']} {vector['label']}" + try: + mutated = apply_vector(copy.deepcopy(doc), vector) + except Exception as error: # noqa: BLE001 - 向量失效(锚点没了)同样算红,逼着人更新它 failures += 1 - print(f"FAIL: 变异向量过期(原文片段已不在 {WORKFLOW.name} 里):{label}", file=sys.stderr) + print(f"FAIL: 变异「{label}」注入失败(向量失效):{error}", 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: + broken = [name for name, problems in evaluate(mutated).items() if problems] + if vector["expect"] not in broken: failures += 1 - print(f"FAIL: 变异「{label}」没被抓到(期望判据「{expected}」红,实际红的是 {got or '无'})", file=sys.stderr) + print(f"FAIL: 变异「{label}」没被抓到(应红「{vector['expect']}」,实际红的是 {broken or '无'})", file=sys.stderr) else: - print(f"OK: 变异「{label}」→ 判据「{expected}」当场红") + print(f"OK: 变异「{label}」→ 「{vector['expect']}」当场红") if failures: print(f"FAIL:{failures} 处没过({WORKFLOW.name})", file=sys.stderr) return 1 - print(f"ok: {len(CHECKS)} 条接线判据全过,{len(VECTORS)} 条变异向量全部被抓({WORKFLOW.name})") + print(f"ok: {len(CHECKS)} 条接线判据全过,{len(vectors)} 条变异向量全部被抓({WORKFLOW.name})") + print(" 边界:本脚本防「顺手改绿」,不防「连判据一起改」;`if:` 语义由本脚本的小求值器模拟,不是 GitHub 本身。") return 0 diff --git a/packaging/tests/gpu_shot_ci_vectors.json b/packaging/tests/gpu_shot_ci_vectors.json new file mode 100644 index 0000000..79872a6 --- /dev/null +++ b/packaging/tests/gpu_shot_ci_vectors.json @@ -0,0 +1,26 @@ +{ + "_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 键逐字一致)。", + "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": "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": "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": "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": "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 判定用探测给的产物"} + ] +} From 8722c5a0abed7a2432813af69e265c7cdb679002 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 18:31:30 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(ci)+test(packaging):=20=E5=85=B3?= =?UTF-8?q?=E6=8E=89=E3=80=8C=E9=97=A8=E7=A6=81=E5=85=B3=E6=AD=BB=E8=80=8C?= =?UTF-8?q?=E8=87=AA=E6=A3=80=E5=85=A8=E7=BB=BF=E3=80=8D=E4=B8=89=E6=9D=A1?= =?UTF-8?q?=E8=B7=AF=EF=BC=9Aoutputs=20=E7=BB=91=E5=AE=9A=E3=80=81?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E6=AD=A5=E7=9C=9F=E8=B7=91=E3=80=81=E8=87=AA?= =?UTF-8?q?=E6=A3=80=E6=AD=A5=E6=8C=87=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)。 --- .github/workflows/gpu-evidence.yml | 7 +- packaging/README.md | 7 +- packaging/docs/gpu-evidence.md | 44 +++-- packaging/tests/gpu_shot_ci_expr.py | 64 ++++++ packaging/tests/gpu_shot_ci_probe.py | 90 ++++++++- packaging/tests/gpu_shot_ci_selftest.py | 241 ++++++++++++++--------- packaging/tests/gpu_shot_ci_vectors.json | 44 +++-- 7 files changed, 358 insertions(+), 139 deletions(-) create mode 100644 packaging/tests/gpu_shot_ci_expr.py diff --git a/.github/workflows/gpu-evidence.yml b/.github/workflows/gpu-evidence.yml index 07cee86..8804e51 100644 --- a/.github/workflows/gpu-evidence.yml +++ b/.github/workflows/gpu-evidence.yml @@ -9,7 +9,7 @@ name: GPU evidence # # 口径(塔定,M253 Context 原文):**CI runner 无 GPU 时必须显式 skip 并写明原因,不得静默 pass**。 # 落到这份文件的三条硬规矩 —— 它们由 `packaging/tests/gpu_shot_ci_selftest.py` 机器校验 -# (21 条「改绿」变异逐条重放),不是靠这几行注释自律;那份脚本的 docstring 写清它**保证什么、 +# (33 条「改绿」变异逐条重放、10 类封闭清单),不是靠这几行注释自律;那份脚本的 docstring 写清它**保证什么、 # 保证不了什么**(r1 复审实测:上一版 19 条变异漏 11 条,所以这里不敢再写「不可能」): # ① `selftest` job **无条件**跑 `--self-test`:它证明「尺子合格」,不需要 GPU、也不需要真产物; # 同一 job 再用真 CLI 走一遍三个退出码(0 过 / 1 判据不过 / 2 用法错)。 @@ -18,6 +18,9 @@ name: GPU evidence # **灰色 skipped**,不是一个绿色的 ✓;同时 `probe` 在 job summary 里写明缺什么、为什么, # 并打 `::warning::` 说明「本次没有判过任何真实产物」。 # +# 载体依赖:整个 `gpu-evidence.yml` 被删掉/替换后,**没有任何东西会跑这个自检** —— 守门人住在被守对象自己的文件里(`guards.yml` 已经 399/400 行,塞不进去)。 +# 所以改这道门禁时请连自检一起想:自检只认「上面那张封闭清单」,不宣称「不可能被改成总是绿」。 +# # 判据本身(六条 + `--expect-refuse` 三条)与能证/不能证:`packaging/docs/gpu-evidence.md`。 # 改这份文件先跑 `python3 packaging/tests/gpu_shot_ci_selftest.py`。 @@ -94,7 +97,7 @@ jobs: python3 packaging/check_gpu_shot.py "$work/red.stderr.log" "$work/pass.png" expect 2 "用法错(一个位置参数都没给)" \ python3 packaging/check_gpu_shot.py - - name: 接线自检(21 条「改绿」变异必须重放为红;覆盖边界见脚本 docstring) + - name: 接线自检(33 条「改绿」变异必须重放为红;覆盖边界见脚本 docstring) id: wiring run: python3 packaging/tests/gpu_shot_ci_selftest.py diff --git a/packaging/README.md b/packaging/README.md index 5f9760a..c1268ad 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -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..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 | diff --git a/packaging/docs/gpu-evidence.md b/packaging/docs/gpu-evidence.md index 44491e7..98384d0 100644 --- a/packaging/docs/gpu-evidence.md +++ b/packaging/docs/gpu-evidence.md @@ -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 diff --git a/packaging/tests/gpu_shot_ci_expr.py b/packaging/tests/gpu_shot_ci_expr.py new file mode 100644 index 0000000..f0b8bb5 --- /dev/null +++ b/packaging/tests/gpu_shot_ci_expr.py @@ -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..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'[^']*')|(?P\d+)|(?P[A-Za-z_][\w.\-]*)|(?P==|!=|&&|\|\||[()!])") + + +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 diff --git a/packaging/tests/gpu_shot_ci_probe.py b/packaging/tests/gpu_shot_ci_probe.py index 194db00..1b4d266 100644 --- a/packaging/tests/gpu_shot_ci_probe.py +++ b/packaging/tests/gpu_shot_ci_probe.py @@ -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] diff --git a/packaging/tests/gpu_shot_ci_selftest.py b/packaging/tests/gpu_shot_ci_selftest.py index d5dd36c..5ee5b88 100644 --- a/packaging/tests/gpu_shot_ci_selftest.py +++ b/packaging/tests/gpu_shot_ci_selftest.py @@ -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..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..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'[^']*')|(?P\d+)|(?P[A-Za-z_][\w.\-]*)|(?P==|!=|&&|\|\||[()!])") - - -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 diff --git a/packaging/tests/gpu_shot_ci_vectors.json b/packaging/tests/gpu_shot_ci_vectors.json index 79872a6..ce8b8a7 100644 --- a/packaging/tests/gpu_shot_ci_vectors.json +++ b/packaging/tests/gpu_shot_ci_vectors.json @@ -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)"} ] }