fix(ci)+test(packaging): 接线自检不再给假安全证书:19 条变异重放全红,dev 线接上
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 上做不到」。
措辞:三处「不可能被改成总是绿」改成能复跑的覆盖面 + 边界。
This commit is contained in:
1 parent
075769c6d2
commit
2e9702790d
7 files changed
+483
-162
No files matched your search
@@ -49,6 +49,14 @@ jobs:
|
|||||||
name: 架构与合规守门(同 main 口径)
|
name: 架构与合规守门(同 main 口径)
|
||||||
uses: ./.github/workflows/guards.yml
|
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:
|
fmt:
|
||||||
name: fmt (rustfmt --check)
|
name: fmt (rustfmt --check)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ name: GPU evidence
|
|||||||
#
|
#
|
||||||
# 口径(塔定,M253 Context 原文):**CI runner 无 GPU 时必须显式 skip 并写明原因,不得静默 pass**。
|
# 口径(塔定,M253 Context 原文):**CI runner 无 GPU 时必须显式 skip 并写明原因,不得静默 pass**。
|
||||||
# 落到这份文件的三条硬规矩 —— 它们由 `packaging/tests/gpu_shot_ci_selftest.py` 机器校验
|
# 落到这份文件的三条硬规矩 —— 它们由 `packaging/tests/gpu_shot_ci_selftest.py` 机器校验
|
||||||
# (带变异向量),不是靠这几行注释自律:
|
# (21 条「改绿」变异逐条重放),不是靠这几行注释自律;那份脚本的 docstring 写清它**保证什么、
|
||||||
|
# 保证不了什么**(r1 复审实测:上一版 19 条变异漏 11 条,所以这里不敢再写「不可能」):
|
||||||
# ① `selftest` job **无条件**跑 `--self-test`:它证明「尺子合格」,不需要 GPU、也不需要真产物;
|
# ① `selftest` job **无条件**跑 `--self-test`:它证明「尺子合格」,不需要 GPU、也不需要真产物;
|
||||||
# 同一 job 再用真 CLI 走一遍三个退出码(0 过 / 1 判据不过 / 2 用法错)。
|
# 同一 job 再用真 CLI 走一遍三个退出码(0 过 / 1 判据不过 / 2 用法错)。
|
||||||
# ② 真实产物判据只在 `probe` job 探到**工作区里真有可用产物**时才跑。
|
# ② 真实产物判据只在 `probe` job 探到**工作区里真有可用产物**时才跑。
|
||||||
# ③ **跳过与通过分开写**:跳过的做法是让 `evidence` job 的 `if:` 不成立 —— 它在 Actions 里显示
|
# ③ **跳过与通过分开写**:跳过的做法是让 `evidence` job 的 `if:` 不成立 —— 它在 Actions 里显示
|
||||||
# **灰色 skipped**,不是一个绿色的 ✓;同时 `probe` 在 job summary 里写明缺什么、为什么,
|
# **灰色 skipped**,不是一个绿色的 ✓;同时 `probe` 在 job summary 里写明缺什么、为什么,
|
||||||
# 并打 `::warning::` 说明「本次没有判过任何真实产物」。`evidence` 永远不会在没判过时给绿。
|
# 并打 `::warning::` 说明「本次没有判过任何真实产物」。
|
||||||
#
|
#
|
||||||
# 判据本身(六条 + `--expect-refuse` 三条)与能证/不能证:`packaging/docs/gpu-evidence.md`。
|
# 判据本身(六条 + `--expect-refuse` 三条)与能证/不能证:`packaging/docs/gpu-evidence.md`。
|
||||||
# 改这份文件先跑 `python3 packaging/tests/gpu_shot_ci_selftest.py`。
|
# 改这份文件先跑 `python3 packaging/tests/gpu_shot_ci_selftest.py`。
|
||||||
@@ -38,6 +39,12 @@ on:
|
|||||||
description: '按负例口径判(--expect-refuse:要求 via=refuse 且一张图都没出)'
|
description: '按负例口径判(--expect-refuse:要求 via=refuse 且一张图都没出)'
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
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:
|
jobs:
|
||||||
selftest:
|
selftest:
|
||||||
@@ -46,10 +53,12 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: 判据实现自测(19 条向量,数据在 packaging/tests/gpu_shot_vectors.json)
|
- name: 判据实现自测(19 条向量,数据在 packaging/tests/gpu_shot_vectors.json)
|
||||||
|
id: vectors
|
||||||
run: python3 packaging/check_gpu_shot.py --self-test
|
run: python3 packaging/check_gpu_shot.py --self-test
|
||||||
# 自测走的是判据**函数**,退出码是 `main()` 的那一行。这里用真 CLI 把三个码都走一遍:
|
# 自测走的是判据**函数**,退出码是 `main()` 的那一行。这里用真 CLI 把三个码都走一遍:
|
||||||
# 少了这一步,「退出码永远 0」这种回归在 CI 上照样全绿(门禁自己变成假绿)。
|
# 少了这一步,「退出码永远 0」这种回归在 CI 上照样全绿(门禁自己变成假绿)。
|
||||||
- name: 退出码语义(0 过 / 1 判据不过 / 2 用法错)
|
- name: 退出码语义(0 过 / 1 判据不过 / 2 用法错)
|
||||||
|
id: exitcodes
|
||||||
run: |
|
run: |
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
work="$RUNNER_TEMP/gpu-shot-exitcodes"
|
work="$RUNNER_TEMP/gpu-shot-exitcodes"
|
||||||
@@ -85,7 +94,8 @@ jobs:
|
|||||||
python3 packaging/check_gpu_shot.py "$work/red.stderr.log" "$work/pass.png"
|
python3 packaging/check_gpu_shot.py "$work/red.stderr.log" "$work/pass.png"
|
||||||
expect 2 "用法错(一个位置参数都没给)" \
|
expect 2 "用法错(一个位置参数都没给)" \
|
||||||
python3 packaging/check_gpu_shot.py
|
python3 packaging/check_gpu_shot.py
|
||||||
- name: 接线自检(本 workflow 不许被改成「总是绿」)
|
- name: 接线自检(21 条「改绿」变异必须重放为红;覆盖边界见脚本 docstring)
|
||||||
|
id: wiring
|
||||||
run: python3 packaging/tests/gpu_shot_ci_selftest.py
|
run: python3 packaging/tests/gpu_shot_ci_selftest.py
|
||||||
|
|
||||||
probe:
|
probe:
|
||||||
@@ -181,21 +191,27 @@ jobs:
|
|||||||
echo "- 结论:**⏭ 跳过(未验证)** —— ${reason}"
|
echo "- 结论:**⏭ 跳过(未验证)** —— ${reason}"
|
||||||
echo "- 「这张图真是 GPU 档出的」这件事,**本次没有判过**;跳过不等于通过。"
|
echo "- 「这张图真是 GPU 档出的」这件事,**本次没有判过**;跳过不等于通过。"
|
||||||
echo "- 已经验过的是尺子本身:\`--self-test\`(19 条向量)与三个退出码 0/1/2(\`selftest\` job)。"
|
echo "- 已经验过的是尺子本身:\`--self-test\`(19 条向量)与三个退出码 0/1/2(\`selftest\` job)。"
|
||||||
echo "- 想让它变成真判定:\`workflow_dispatch\` 填 \`shot_log\` / \`shot_png\`,"
|
echo "- 想让它变成真判定,只有两条**在这个 CI 运行里真能执行**的路:"
|
||||||
echo " 或在有 GPU 的机器上取证后放到 \`${default_dir}/${default_stem}.stderr.log\` + \`${default_dir}/${default_stem}.png\`。"
|
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
|
fi
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
evidence:
|
evidence:
|
||||||
name: GPU 证据 — 真实产物判据
|
name: GPU 证据 — 真实产物判据
|
||||||
needs: [selftest, probe]
|
needs: [selftest, probe]
|
||||||
# 第 ③ 条规矩就在这一行:没有可用产物 ⇒ 整个 job 不运行(UI 显示 skipped),
|
# 第 ③ 条规矩就在这一行:没有可用产物 ⇒ 整个 job 不运行,UI 上是灰色 skipped,
|
||||||
# 所以它在「没判过」的情况下不可能显示成一个绿色的 ✓。
|
# 不是一个绿色 ✓。这正是「跳过」与「通过」分开写的地方(r1 复审实核过这条 if 的语义)。
|
||||||
if: needs.probe.outputs.available == 'true'
|
if: needs.probe.outputs.available == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: 判定(0 过 / 1 判据不过 / 2 用法错;退出码即结论,不许被 || true 吞掉)
|
- name: 判定(0 过 / 1 判据不过 / 2 用法错;退出码即结论,不许被 || true 吞掉)
|
||||||
|
id: judge
|
||||||
env:
|
env:
|
||||||
LOG: ${{ needs.probe.outputs.log }}
|
LOG: ${{ needs.probe.outputs.log }}
|
||||||
PNG: ${{ needs.probe.outputs.png }}
|
PNG: ${{ needs.probe.outputs.png }}
|
||||||
|
|||||||
+5
-3
@@ -69,8 +69,10 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
|||||||
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
|
| `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 |
|
||||||
| `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤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 |
|
| `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 |
|
| `.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):5 条判据(自测步不许有 `if:`、真判据必须挂 `outputs.available == 'true'`、跳过那步必须 summary+`跳过`+`::warning::`+`$GITHUB_OUTPUT`、全文件禁 `continue-on-error`、判据命令不许 `|| true`)+ 5 条变异向量(把每种「改绿」写法注入临时副本必须当场红) | ≤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_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/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 |
|
| `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/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 |
|
||||||
| `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 |
|
| `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 |
|
||||||
| `packaging/docs/linux-evidence.md` | Linux 槽位的证据账(M184):现有证据盘点、WSLg 真机实测输出、xvfb 复跑、CI 增量估算、未验清单 | ≤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-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 |
|
||||||
| `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 |
|
| `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 |
|
||||||
| `.github/workflows/release.yml` | tag 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 |
|
| `.github/workflows/release.yml` | tag 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 |
|
||||||
|
|||||||
@@ -139,11 +139,19 @@ M246 本机实测是 **5.69% / 200**(gallery)与 **2.13% / 75**(shell)
|
|||||||
|
|
||||||
## 8. CI 接线(M253):无 GPU 时显式跳过,**跳过 ≠ 通过**
|
## 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` 那道门
|
**为什么新开一个文件而不是并进 `guards.yml`**:`guards.yml` 已是 399 行、红线 400(`file-size` 那道门
|
||||||
就扫 `*.yml`),挂一个 job 必越线;`release-build.yml` 正好 400 行、动它得先拆。与 `release-guard.yml`
|
就扫 `*.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,各管一件事:
|
||||||
|
|
||||||
| job | 什么时候跑 | 它的结论代表什么 |
|
| job | 什么时候跑 | 它的结论代表什么 |
|
||||||
@@ -173,21 +181,39 @@ GPU 证据:无硬件适配器(/dev/dri 下没有 renderD*,也没有可用
|
|||||||
| 点了名但**文件不存在**(或正例没给 PNG) | **红**(`::error::` + exit 1) | 「明确说要判、东西不在」若能退化成跳过,就又是一次假绿 —— 有意为之的输入缺失是错误,不是 skip |
|
| 点了名但**文件不存在**(或正例没给 PNG) | **红**(`::error::` + exit 1) | 「明确说要判、东西不在」若能退化成跳过,就又是一次假绿 —— 有意为之的输入缺失是错误,不是 skip |
|
||||||
| 托管 runner 常态(无 GPU + 无产物) | 跳过 + 原因 + 黄条 | 见上;托管 runner 本来就没有硬件适配器 |
|
| 托管 runner 常态(无 GPU + 无产物) | 跳过 + 原因 + 黄条 | 见上;托管 runner 本来就没有硬件适配器 |
|
||||||
|
|
||||||
**接线不许被改成「总是绿」**:`packaging/tests/gpu_shot_ci_selftest.py` 把这条口径变成机器判据 ——
|
**在 CI 里怎么让它真判**(r1 复审 P2-2:原先 §8 说的「把产物放到 `DevDocs/`」在 CI 里没人做得到 ——
|
||||||
自测步不许有 `if:`、真实判据必须挂在 `outputs.available == 'true'` 上、跳过那步必须同时写
|
**checkout 只拿已跟踪文件**,而 `/DevDocs/` 在 `.gitignore:6` 里,也没有 `upload/download-artifact` 环节):
|
||||||
`$GITHUB_STEP_SUMMARY` + `跳过` + `::warning::` + `$GITHUB_OUTPUT`、全文件不许 `continue-on-error`、
|
|
||||||
判据命令不许 `|| true`。它自带 5 条变异向量:把上面每种「改绿」写法注入临时副本,对应判据必须当场红。
|
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 这一节**没验**的部分
|
### 8.1 这一节**没验**的部分
|
||||||
|
|
||||||
- **CI 上从没真跑过**:本 workflow 是本地写的,**一次 GitHub Actions 运行都没有**(未 push)。
|
- **CI 上从没真跑过**:本 workflow 是本地写的,**一次 GitHub Actions 运行都没有**(未 push)。
|
||||||
本地做的是**手模拟**:把三个 step 的 `run:` 原文抽出来用 bash 跑(`GITHUB_OUTPUT` /
|
本地验的是两件事:① 把三个 step 的 `run:` 原文抽出来用 bash 跑(跳过 / 约定成对 / 约定半套 /
|
||||||
`GITHUB_STEP_SUMMARY` 指向临时文件),四条路径(跳过 / 约定产物 / dispatch 产物 / dispatch 缺失即红)
|
dispatch 成对 / 点名不存在 / 正例缺 PNG / 负例 —— 这 7 条已由判据 4 **每次自检都重跑**);
|
||||||
与 `evidence` 的 0/1/2 都按预期。**这不是 CI 实测**:`if:` 求值、job skip 在 UI 上的呈现、
|
② `evidence` 的判据命令本地实跑过 0 与 1。**这不是 CI 实测**:`if:` 求值、job skip 在 UI 上的呈现、
|
||||||
`needs` 的输出传递在真 Actions 里仍未跑过。
|
`needs` 的输出传递在真 Actions 里仍未跑过。
|
||||||
- **没在真 GPU runner 上跑过**:`evidence` 的真判定只在本地用合成向量走过(0 与 1 都到达过),
|
- **没在真 GPU runner 上跑过**:`evidence` 的真判定只在本地用合成向量走过(0 与 1 都到达过),
|
||||||
「真产物 + 真 GPU runner」这个组合没实测。
|
「真产物 + 真 GPU runner」这个组合没实测。
|
||||||
- **没验「跳过是否满足 required status check」**:本 workflow 没挂成必需检查;GitHub 对 skipped job
|
- **没验「跳过是否满足 required status check」**:本 workflow 没挂成必需检查;GitHub 对 skipped job
|
||||||
与 required check 的交互口径没实测,别把这一节读成「跳过也能当门过」。
|
与 required check 的交互口径没实测,别把这一节读成「跳过也能当门过」。
|
||||||
- **没接日常线 `dev`**:`dev-guards.yml` 复用的是 `guards.yml`,本文件不在其中;挂上去要多一次
|
- **自检脚本自身的边界**在它自己的 docstring 里(不防「连判据一起改」;`!` 不在支持的表达式语法里,
|
||||||
`uses:`,那是新的成本决定,不在 M253 里顺手做。
|
出现即红;只有 `probe` 那一步是功能断言,`vectors`/`exitcodes` 两步只做结构断言)。
|
||||||
@@ -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]
|
||||||
@@ -1,50 +1,127 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""`gpu-evidence.yml` 的接线自检(M253):断言这道门禁**不可能被改成「总是绿」**。
|
r"""`gpu-evidence.yml` 的**接线自检**(M253 / r1 复审返工):把「这道门禁怎么才算没被改绿」变成判据。
|
||||||
|
|
||||||
为什么需要它:`check_gpu_shot.py --self-test` 只证明「尺子能量」,它管不到**接线**——
|
历史教训(必须留着):r1 那一版宣称「不可能被改成总是绿」,复审独立造了 19 个「改成总是绿」的变异,
|
||||||
把自测步写成 `if: false`、给真实判据步加 `continue-on-error: true`、或者把命令尾巴写成
|
**11 个它照样绿** —— 最要命的是把 `available` 写成恒 `false`:门禁永久关闭,而 push/PR 本来就跳过,
|
||||||
`|| true`,三种改法的结局都一样:job 变绿、一次判据都没真的跑过。本仓已经栽过四次
|
**肉眼与机器都看不出**。原因有三:判据 2/3 本质是字符串存在性检查、判据 5 把自测步整条豁免且不扫
|
||||||
「守卫假绿」,所以这里把接线口径变成机器判据,而不是让下一个人读注释自律。
|
`exit 0`、判据 4 用 `is True` 挡不住 `'true'` / `${{ true }}`。那一版给的是一张**假的安全证书**。
|
||||||
|
|
||||||
五条判据(对应 workflow 头部的三条规矩):
|
现在六条判据,能结构化的不靠字符串、能跑的不靠读文本:
|
||||||
|
|
||||||
1. **自测无条件跑**:跑 `--self-test` 的那一步、以及它所在的 job,都不许有 `if:`;
|
| # | 判据 | 怎么判 | 抓的是什么 |
|
||||||
2. **真实判据必须挂在门上**:跑真产物判据的步(或它的 job)的 `if:` 必须引用
|
|---|------|--------|-----------|
|
||||||
`needs.<probe>.outputs.available == 'true'` —— 没有门就是「无产物时也照跑」,
|
| 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. **跳过要留痕**:至少一步同时写着 `$GITHUB_STEP_SUMMARY`、`跳过`、`::warning::` 与
|
| 3 | 判定用的是探测给的产物 | `run` 里**两种调用形态都必须在场**(`check_gpu_shot.py "$LOG" "$PNG"` 与 `... "$LOG" --expect-refuse`),且 `env` 来自 `needs.probe.outputs.*` | 写死路径、判错文件、把某一分支换成 `true`(那个分支啥都没判就绿) |
|
||||||
`$GITHUB_OUTPUT`(写 summary + 打黄条 + 把 available 交出去),不许静默 pass;
|
| 4 | 跳过路径**真跑** | 把 `detect` 步的 `run` 原文抽到临时目录、用桩 env **实跑 7 种输入**,断言 `$GITHUB_OUTPUT` 里的 `available`、job summary 文案、`::warning::`,以及三条红线必须 `exit≠0` | `available` 恒 false、跳过不留痕、红线退化成跳过 |
|
||||||
4. **全文件不许 `continue-on-error`**:判据红的时候它会把 job 变绿;
|
| 5 | 禁 `continue-on-error` | 按 **YAML 布尔语义**(`true` / `'true'` / `${{ true }}` / 任意表达式都算开启,只有 `false`/`'false'`/`${{ false }}`/`0` 算关闭) | 带引号、表达式形式 |
|
||||||
5. **判据命令不许吞退出码**(`|| true` / `|| exit 0` / `; true`),且真实判据步的产物路径必须
|
| 6 | 不吞退出码 | 扫**所有** `run` 里同时出现 `python`/`python3` 与 `packaging/` 的步(自测步与自检步都在内),`\|\| true` / `; true` / `\|\| exit 0` / 裸 `exit 0` 全算吞 | 自测步与自检步被整条豁免、裸 `exit 0` |
|
||||||
来自 `needs.probe.outputs.*`(不是写死的)。
|
|
||||||
|
|
||||||
自带 **5 条变异向量**:把上面每一种「改绿」写法注入临时副本,断言对应判据当场红、基线绿。
|
再叠一层:`packaging/tests/gpu_shot_ci_vectors.json` 里 **21 条变异向量**(r1 复审那张表里的 20 条 + 一条
|
||||||
判据自己抓不住回归时它会先红,所以这些断言不会变成装饰品(同 `check_release_semantics.py
|
「把判定步的命令换成 `true`」),每条按**解析后的结构**注入(`step_delete` / `job_delete` / `step_set` / `job_set` /
|
||||||
--self-mutation` 的做法)。
|
`run_append` / `run_replace`),每条都必须让对应判据当场红、基线必须全绿。
|
||||||
|
|
||||||
|
**保证不了什么(别读成免责声明,读成边界)**:
|
||||||
|
- 这些判据本身不是自守的:能改这个文件的人也能改判据。它防的是**顺手改绿**,不是恶意改绿。
|
||||||
|
- `if:` 是我自己写的小求值器,不是 GitHub 的语义:只认 `==` / `!=` / `&&` / `||` / 括号 / 字符串 /
|
||||||
|
`needs.<job>.outputs.<名>` / `true|false|null` / `always()|success()|failure()|cancelled()`(后四个按
|
||||||
|
「本次假定上游成功」取常量)。其余一律认不出 ⇒ 红。`!` 不在支持集里(GitHub 与 Python 的 `!` 优先级
|
||||||
|
不同,宁可认不出)。
|
||||||
|
- 只有 `detect` 那一步是**功能断言**;`vectors` / `exitcodes` 两步只做结构断言(它们的命令在 CI 里作为
|
||||||
|
独立步骤真跑,本脚本不重复跑,免得本地没有 `python3` 时把自检本身搞红)。
|
||||||
|
- 真 Actions 里 `if:` 求值、job skip 的呈现、required status check 与 skipped 的交互,本脚本一概不管。
|
||||||
|
|
||||||
python3 packaging/tests/gpu_shot_ci_selftest.py
|
python3 packaging/tests/gpu_shot_ci_selftest.py
|
||||||
|
|
||||||
零第三方依赖,只借 `check_workflows_yaml.load_yaml`(它自己也负责「PyYAML 缺失 ⇒ 门禁不降级」)。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
PACKAGING = Path(__file__).resolve().parent.parent
|
PACKAGING = Path(__file__).resolve().parent.parent
|
||||||
WORKFLOW = PACKAGING.parent / ".github/workflows/gpu-evidence.yml"
|
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(PACKAGING))
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
from check_workflows_yaml import load_yaml # noqa: E402
|
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"
|
#: 必须在场、且必须无条件的步骤(按 `id:` 认,不按步骤名认)。
|
||||||
JUDGE_MARK = "check_gpu_shot.py"
|
REQUIRED_STEPS = {
|
||||||
GATE = "outputs.available"
|
"vectors": "判据实现自测(`--self-test`,19 条向量)",
|
||||||
SWALLOW_MARKS = ("|| true", "|| exit 0", "; true")
|
"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<string>'[^']*')|(?P<number>\d+)|(?P<ident>[A-Za-z_][\w.\-]*)|(?P<op>==|!=|&&|\|\||[()!])")
|
||||||
|
|
||||||
|
|
||||||
|
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]]:
|
def steps(doc: dict) -> list[tuple[str, dict, dict]]:
|
||||||
"""`[(job_id, job, step)]`:只列「列表项形态」的步骤(`uses:` 的 job 没有 steps)。"""
|
|
||||||
found: list[tuple[str, dict, dict]] = []
|
found: list[tuple[str, dict, dict]] = []
|
||||||
for job_id, job in (doc.get("jobs") or {}).items():
|
for job_id, job in (doc.get("jobs") or {}).items():
|
||||||
for step in (job or {}).get("steps") or []:
|
for step in (job or {}).get("steps") or []:
|
||||||
@@ -53,147 +130,164 @@ def steps(doc: dict) -> list[tuple[str, dict, dict]]:
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
def check_selftest_unconditional(doc: dict) -> list[str]:
|
def step_by_id(doc: dict, sid: str) -> tuple[str, dict, dict] | None:
|
||||||
"""判据 1:自测那步不许带任何条件(job 级 or step 级)。"""
|
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] = []
|
problems: list[str] = []
|
||||||
hits = 0
|
counts = {sid: len([e for e in steps(doc) if e[2].get("id") == sid]) for sid in REQUIRED_STEPS}
|
||||||
for job_id, job, step in steps(doc):
|
for sid, label in REQUIRED_STEPS.items():
|
||||||
if SELFTEST_MARK not in (step.get("run") or ""):
|
if counts[sid] != 1:
|
||||||
|
problems.append(f"`id: {sid}`({label})必须恰好一步,实际 {counts[sid]} 步 —— 删掉或复制都算红")
|
||||||
continue
|
continue
|
||||||
hits += 1
|
job_id, job, step = step_by_id(doc, sid)
|
||||||
# 用 `in` 而不是真值判断:`if: false` 会被 YAML 解析成布尔 False,真值判断正好漏掉最想抓的那种写法。
|
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 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:
|
if "if" in job:
|
||||||
problems.append(f"job `{job_id}` 挂了 job 级 `if:` —— 自测被判据挡住了,整条门禁可以静默不跑")
|
gates.append((f"job `{job_id}` 的 `if`", job.get("if")))
|
||||||
if "if" in step:
|
if "if" in step:
|
||||||
problems.append(f"job `{job_id}` 的 `--self-test` 步挂了 `if:` —— 同上,自测必须无条件跑")
|
gates.append(("`id: judge` 步的 `if`", step.get("if")))
|
||||||
if not hits:
|
if not gates:
|
||||||
problems.append(f"没有任何一步无条件跑 `{SELFTEST_MARK}`")
|
return ["`judge` 步与它的 job 都没有 `if:` —— 无产物时也会照跑,「跳过」与「通过」分不开"]
|
||||||
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] = []
|
problems: list[str] = []
|
||||||
real = [
|
for label, raw in gates:
|
||||||
(job_id, job, step)
|
for available, want in (("true", True), ("false", False), ("", False)):
|
||||||
for job_id, job, step in steps(doc)
|
try:
|
||||||
if JUDGE_MARK in (step.get("run") or "") and SELFTEST_MARK not in (step.get("run") or "") and references_probe(step)
|
got = evaluate_condition(raw, available)
|
||||||
]
|
except ConditionError as error:
|
||||||
if not real:
|
problems.append(f"{label} 认不出({error})—— 认不出即红:`{raw}`")
|
||||||
problems.append(
|
break
|
||||||
f"找不到跑真实产物判据的那一步(`{JUDGE_MARK} <日志> <PNG>`,且日志/图来自 `needs.probe.outputs.*`)—— 门禁没接上"
|
if got is not want:
|
||||||
)
|
problems.append(f"{label} 在 `available={available!r}` 时求值为 {got},应为 {want}:`{raw}`")
|
||||||
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
|
return problems
|
||||||
|
|
||||||
|
|
||||||
def check_skip_is_visible(doc: dict) -> list[str]:
|
def check_judge_invocation(doc: dict) -> list[str]:
|
||||||
"""判据 3:跳过必须留痕(summary + warning + 交出去的 available),不许静默 pass。"""
|
entry = step_by_id(doc, "judge")
|
||||||
for _job_id, _job, step in steps(doc):
|
if entry is None:
|
||||||
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 []
|
||||||
return ["没有任何一步同时写着 `$GITHUB_STEP_SUMMARY` + `跳过` + `::warning::` + `$GITHUB_OUTPUT` —— 跳过会变成静默 pass"]
|
run = entry[2].get("run") or ""
|
||||||
|
env = entry[2].get("env") or {}
|
||||||
|
|
||||||
def check_no_continue_on_error(doc: dict) -> list[str]:
|
|
||||||
"""判据 4:`continue-on-error` 会把红的判据变绿,全文件禁用。"""
|
|
||||||
problems: list[str] = []
|
problems: list[str] = []
|
||||||
for job_id, job, _step in steps(doc):
|
# 两种调用形态都必须在场:正例(日志 + PNG)与负例(--expect-refuse,PNG 可选)。
|
||||||
if job.get("continue-on-error") is True:
|
# 少一种 ⇒ 那个分支可以「什么都没判」就绿(r1 复审 M20:把正例那一行换成 `true`,旧判据照样绿)。
|
||||||
problems.append(f"job `{job_id}` 带 `continue-on-error: true`")
|
for form in ('check_gpu_shot.py "$LOG" "$PNG"', 'check_gpu_shot.py "$LOG" --expect-refuse'):
|
||||||
for job_id, _job, step in steps(doc):
|
if form not in run:
|
||||||
if step.get("continue-on-error") is True:
|
problems.append(f"`id: judge` 的 `run` 里缺调用形态 `{form}` —— 该分支可以「什么都没判」就绿")
|
||||||
problems.append(f"job `{job_id}` 的步骤 `{step.get('name') or step.get('uses')}` 带 `continue-on-error: true` —— 判据红也会变成绿")
|
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
|
return problems
|
||||||
|
|
||||||
|
|
||||||
def check_no_swallowed_exit_code(doc: dict) -> list[str]:
|
def check_skip_path(doc: dict) -> list[str]:
|
||||||
"""判据 5:判据命令不许吞退出码(`|| true` / `; true` 会把红判据变成绿)。"""
|
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] = []
|
problems: list[str] = []
|
||||||
for job_id, _job, step in steps(doc):
|
for job_id, _job, step in steps(doc):
|
||||||
run = step.get("run") or ""
|
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
|
continue
|
||||||
name = step.get("name") or f"job {job_id} 里那一步"
|
hits = [mark for mark in SWALLOW_MARKS if mark in run]
|
||||||
for mark in SWALLOW_MARKS:
|
if hits:
|
||||||
if mark in run:
|
problems.append(f"job `{job_id}` 的 `{step.get('id') or step.get('name')}` 里出现 {'/'.join(f'`{h}`' for h in hits)} —— 退出码被吞掉,判据红也绿")
|
||||||
problems.append(f"`{name}` 的命令里出现 `{mark}` —— 退出码被吞掉,判据红也绿")
|
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
CHECKS = {
|
CHECKS = {
|
||||||
"自测无条件": check_selftest_unconditional,
|
"1 五步必须在场": check_presence,
|
||||||
"真实判据挂在门上": check_real_judgement_gated,
|
"2 判定必须挂门": check_judge_gate,
|
||||||
"跳过留痕": check_skip_is_visible,
|
"3 判定用探测给的产物": check_judge_invocation,
|
||||||
"禁用 continue-on-error": check_no_continue_on_error,
|
"4 跳过路径真跑": check_skip_path,
|
||||||
"退出码不被吞": check_no_swallowed_exit_code,
|
"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(doc: dict) -> dict[str, list[str]]:
|
||||||
def evaluate(yaml, text: str) -> dict[str, list[str]]:
|
if not isinstance(doc, dict) or not isinstance(doc.get("jobs"), dict):
|
||||||
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: ["workflow 不是一个带 jobs 的 YAML 映射"] for name in CHECKS}
|
||||||
return {name: check(doc) for name, check in CHECKS.items()}
|
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:
|
def main() -> int:
|
||||||
yaml = load_yaml()
|
yaml = load_yaml()
|
||||||
if not WORKFLOW.is_file():
|
if not WORKFLOW.is_file():
|
||||||
print(f"::error file={WORKFLOW}::接线自检的靶子不存在 —— GPU 证据门禁是不是被删了?", file=sys.stderr)
|
print(f"::error file={WORKFLOW}::接线自检的靶子不存在", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
text = WORKFLOW.read_text(encoding="utf-8")
|
doc = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||||
failures = 0
|
failures = 0
|
||||||
|
for name, problems in evaluate(doc).items():
|
||||||
baseline = evaluate(yaml, text)
|
|
||||||
for name, problems in baseline.items():
|
|
||||||
if problems:
|
if problems:
|
||||||
failures += 1
|
failures += 1
|
||||||
print(f"FAIL: 判据「{name}」没过:", file=sys.stderr)
|
print(f"FAIL: 判据「{name}」没过:", file=sys.stderr)
|
||||||
@@ -202,23 +296,27 @@ def main() -> int:
|
|||||||
else:
|
else:
|
||||||
print(f"OK: 判据「{name}」")
|
print(f"OK: 判据「{name}」")
|
||||||
|
|
||||||
for label, old, new, expected in VECTORS:
|
vectors = json.loads(VECTORS_PATH.read_text(encoding="utf-8"))["vectors"]
|
||||||
if old not in text:
|
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
|
failures += 1
|
||||||
print(f"FAIL: 变异向量过期(原文片段已不在 {WORKFLOW.name} 里):{label}", file=sys.stderr)
|
print(f"FAIL: 变异「{label}」注入失败(向量失效):{error}", file=sys.stderr)
|
||||||
continue
|
continue
|
||||||
mutated = evaluate(yaml, text.replace(old, new, 1))
|
broken = [name for name, problems in evaluate(mutated).items() if problems]
|
||||||
got = [name for name, problems in mutated.items() if problems]
|
if vector["expect"] not in broken:
|
||||||
if expected not in got:
|
|
||||||
failures += 1
|
failures += 1
|
||||||
print(f"FAIL: 变异「{label}」没被抓到(期望判据「{expected}」红,实际红的是 {got or '无'})", file=sys.stderr)
|
print(f"FAIL: 变异「{label}」没被抓到(应红「{vector['expect']}」,实际红的是 {broken or '无'})", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
print(f"OK: 变异「{label}」→ 判据「{expected}」当场红")
|
print(f"OK: 变异「{label}」→ 「{vector['expect']}」当场红")
|
||||||
|
|
||||||
if failures:
|
if failures:
|
||||||
print(f"FAIL:{failures} 处没过({WORKFLOW.name})", file=sys.stderr)
|
print(f"FAIL:{failures} 处没过({WORKFLOW.name})", file=sys.stderr)
|
||||||
return 1
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 判定用探测给的产物"}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user