From 075769c6d219c4f19dc26e1aae82a65740fff41a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 17:53:53 +0800 Subject: [PATCH] =?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())