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