e25e51c378
背景:A-5 残余(docs/任务规划/测试与架构专项.md)说得很直白——mem-numbers 在
CI 被跳过(runner 无 Keil/无 .map),上一轮只把"跳过"声明成一条 ::warning::,
"盲区本身未消除":看到 CI 全绿仍会以为体积也守住了。
复核时本地侧同样漏:ram_baseline.py --gate 在无 .map 时自适应 SKIP 且返回 0;
mem-budget 甚至没进 CI 的 SKIP 名单——它在 runner 上以"Passed"的面貌出现,实际
什么都没检查。两处共同点是**"没跑 = 通过"**。
做法:给体积数字配一条可提交、CI 可复核的**同码凭证**,而不是要求 runner 装 Keil。
- 新增 scripts/check_size_gate.py(口径与边界的唯一正文在其模块 docstring)
· --stamp:只在三条都成立时才盖章——① .map 存在且解析得出执行区与 Grand
Totals;② .map 不早于指纹范围内任何源码的 mtime;③ 受管区占比不超过
mem-budget 用的同一常数。凭证因此不是随手可写的。
· --local:工作树指纹必须等于凭证指纹,否则 FAIL(="改了代码但体积从没测过")
· --ci:不需要 Keil 也能判的两条——同码性 + 按凭证记录的数字复核 92% 红线
· --show:排障用。指纹 = git ls-files 范围内逐文件 sha256(路径+内容) 再聚合,
覆盖 App/ Drivers/ Src/ Inc/ Lib/ Middlewares/ test/board/ 与 MDK-ARM 的 .sct/.s
- 新增 scripts/size_attestation.json:本树的凭证(由本树一次真实 @build 盖戳)
- scripts/ram_baseline.py:--update 顺手盖戳——"每次 @build 后跑 --update"是既有
纪律,不新增人工步骤;在函数内 import 防成环;盖戳失败只告警、不改 --update
返回值(新鲜度由门禁自己 fail-closed 兜)。_util/_read_tsv 提为公开名
region_pct / read_tsv_rows 供其复用(体积口径禁第二处定义);--gate 的无 .map
文案改成明说"SKIP 不等于 PASS"并指向兜底路径。
- scripts/ci_local.py:新增 L0c 体积门禁步(fail-closed、无条件照跑、约 0.3 s)
与 --size-only 开关;docstring 里把"无 .map 时跳过两根钩子"的语义补全。
- .gitea/workflows/ci.yml:L0 作业末尾加「体积门禁」步(if: always(),无需 Keil);
主干 push 拦红、其余事件 --warn-only(分支上代码尚未定型,拦红只造噪音,还会
诱发多条道同时抢写 scripts/ 台账);SKIP 名单补 mem-budget,让它诚实显示
Skipped 而不是假 Passed;文件头的"门禁盲区声明"改写为"已关闭为强制路径 +
仍守不住的三项"。run: 块全部纯 ASCII(PS 5.1 无 BOM 按 GBK 解码的实测坑)。
- scripts/README.md:登记新脚本与凭证文件,补用法行。
验证(工作树 _wt/A5-size-gate,分支 task/A5-size-gate)
- @build 0 错 0 警:Code=213780 RO=18860 RW=1204 ZI=131852——与 ram_baseline.tsv
的 2026-10-24 行逐项相同(本改动不碰固件源码,指纹不变,零体积增量)。
- 门禁实跑全 0:check-yaml / trailing-whitespace / end-of-file-fixer /
check-added-large-files / no-mojibake / id-check / doc-links / mem-budget。
- 负向 8 例全过:改凭证指纹 → --local 与 --ci 都 rc=1;凭证占比改成 95% →
--ci rc=1,加 --warn-only 则 rc=0;凭证写成非法 JSON → rc=1;用 mtime 回拨的
.map 去 --stamp → 拒绝且不动凭证;把区 max 改小到必然超线再 --stamp → 同样拒绝。
- 正向:--stamp 之后 --local / --ci 均 rc=0,并与 tsv 末行互证同源。
- --update 的盖戳联动单独验(把 TSV_PATH 与凭证指到仓库内已 gitignore 的
ci-artifacts/ 探针文件,跑完清理,本树台账未被测试污染)。
- ci_local 全量:L0.5 33 runner / 2227 断言全绿、gitlint 10/10、L0c rc=0。L0 里
test-cppcheck 与 test-coverage 各报过一次"files were modified"——单独重跑两条
均 rc=0 且工作树无改动,判定为三条道并行跑主机单测时 %TEMP% 里按套件名派生
的 obj/exe 互相踩踏(harness 产物名全机共用一个 TEMP 目录),非本改动引入。
RAM 增量:0 B(未动固件源码,凭证数字与本树一次真实链接一致)。
gate:全部通过,未使用 --no-verify。
遗留 / 需要 lead 裁定
1. 合入后主干 push 会要求"同码测量":done 流程在 @build 0/0 之后要多跑一条
python scripts/ram_baseline.py --update,并把 scripts/size_attestation.json
(数字变了则连 scripts/ram_baseline.tsv)一并提交。这就是"强制"的含义,但
确实新增半步——若你想先只在本地强制(L0c),我把 CI 那步改成 --warn-only。
2. 文档口径同步我没做(docs/任务规划/**、docs/门禁说明/**、docs/CI/README.md
归主树):A-5 销项时建议把"CI 不守体积"改为"CI 守同码性 + 红线,绝对数字仍
由本地真实构建产生",正文引用 check_size_gate.py 的模块 docstring。
3. 编译器选项(uvprojx)刻意不进指纹:UV4 每次构建改写它且不入库,进指纹会让
门禁在"刚刚构建完"的那一刻自己变红;代价是改优化等级/宏不触发本门禁,需人工
重测——已写进边界正文。
561 lines
31 KiB
YAML
561 lines
31 KiB
YAML
# Gitea Actions CI —— STM32F4-Base 持续集成
|
||
#
|
||
# 覆盖范围(**只做不依赖硬件的层**,见 docs/测试说明/测试开发标准流程.md §1/§9):
|
||
# L0 静态门禁 = pre-commit 全部钩子(等价本地 `pre-commit run --all-files`)
|
||
# L0.5 主机侧纯逻辑单测 = test/host/**(MSVC 离线编译,零硬件)
|
||
#
|
||
# ⛔ L1 板端套件(test/board/,需真机 + ST-LINK)/ L2 @test 闭环 / L3 人工目检
|
||
# **不上 CI** —— 需要开发板与烧录器,只能在本地跑。
|
||
#
|
||
# 明确不做的两件事(都是刻意为之):
|
||
# 1. `@build`(Keil UV4)—— 需本机 Keil 授权,runner 上不具备;且 L0 的 cppcheck +
|
||
# 代码规范已覆盖"编译前的静态问题",真编译仍是开发者的本地红线(AGENTS.md)。
|
||
# 2. `mem-numbers` / `mem-budget` 两根钩子 —— 事实源都是 `MDK-ARM/**/*.map`(Keil
|
||
# 构建产物、已 gitignore),无 `@build` 即无 `.map`。故本 workflow 在**检测不到
|
||
# .map 时自动跳过它们**(见下方 "决定跳过哪些钩子" 步骤),有 .map 的 runner 照常跑。
|
||
#
|
||
# 🔴 体积门禁:盲区已关闭为**强制路径**(A-5 残余,2026-10-25;原 S14 只做到"声明")
|
||
# 跳过钩子只解释了"runner 没有 Keil",没解决**"没跑 = 通过"**:钩子在无 .map 时
|
||
# 自适应 SKIP 并返回 0,于是 CI 全绿仍可能被读成"体积也守住了"。现由 L0 作业**最后
|
||
# 一步** `python scripts/check_size_gate.py --ci` 在**不需要 Keil** 的前提下补两条判据:
|
||
# · 同码性:`scripts/size_attestation.json` 的源码指纹必须等于本次检出树的指纹,
|
||
# 而该凭证只能由"新鲜 .map + 当时未超线"的真实链接测量写出(见 --stamp 三道前置)
|
||
# ——等价于"这份代码至少被 Keil 链接并核算过一次";
|
||
# · 红线:该次测量记录的受管区占比 ≤92%,与 `mem-budget` 同一常数同一口径。
|
||
# 强制落点 = **主干 push**(唯一能保证"这就是要发出去的那份"的位置);其余事件降级为
|
||
# `::warning::`——任务分支上的代码尚未定型,拦红只制造噪音,还会诱发多条道同时
|
||
# 抢写 scripts/ 台账。仍守不住的(**口径正文唯一**在 scripts/check_size_gate.py 的
|
||
# 模块 docstring,此处不重复):绝对数字由本地真实构建产生(runner 无 Keil 许可证)、
|
||
# 编译器选项(uvprojx,UV4 每次改写且不入库)不在指纹范围内。
|
||
#
|
||
# 产物存档(S3,2026-10-02):两个 job 把关键输出 `Tee-Object` 到 `ci-artifacts/*.log`,
|
||
# 并写 $GITHUB_STEP_SUMMARY(Gitea UI 直接可见)+ `::notice::` 汇总行。
|
||
# **刻意不用 `actions/upload-artifact`**——内网可能拉不到外层 action(见 docs/CI/远程CI完整指南.md §5.3);
|
||
# 零依赖方案下日志留在 runner 工作区,UI 通过 step-summary 即可排障。
|
||
# ⚠️ `ci-artifacts/` 已 gitignore(运行时产物)。
|
||
#
|
||
# 触发:push(除纯文档路径外)与 pull_request。
|
||
#
|
||
# 🔴 shell 用 `powershell` 而非 `pwsh`(2026-10-02 实测修正):
|
||
# `pwsh` = PowerShell 7,**不是 Windows 自带**;本机 runner 只有
|
||
# Windows PowerShell 5.1(`C:\Windows\System32\WindowsPowerShell\v1.0\`),
|
||
# 用 `shell: pwsh` 会直接报 `Cannot find: pwsh in PATH` → job 失败(跑不到任何业务逻辑)。
|
||
# `shell: powershell` 在任意 Windows 上都存在。
|
||
#
|
||
# 🔴🔴 `run:` 脚本内容必须【纯 ASCII】(2026-10-02 第二次实测修正):
|
||
# runner 把每个 `run:` 块写入临时 `.ps1`(`...\act\workflow\N.ps1`)再交给
|
||
# Windows PowerShell 5.1 执行,而该临时文件**无 BOM** → PS 5.1 按 **GBK/CP936** 解码。
|
||
# 脚本里的 UTF-8 中文字节会与相邻引号错位 → 报
|
||
# `TerminatorExpectedAtEndOfString` / `字符串缺少终止符: "` / `缺少右"}"`
|
||
# (报错行号正是含中文的那一行,极易误判为逻辑错误)。
|
||
# **故本文件所有 `run:` 块内零非 ASCII**(中文仅出现在 YAML 的 name:/注释里——
|
||
# 那些由 Gitea 按 UTF-8 读,不喂给 PowerShell,安全)。
|
||
# ⚠️ 与 AGENTS.md「字符串字面量必须纯 ASCII」同源;改脚本时务必守住。
|
||
#
|
||
# 🔴🔴 工具链探测必须【可离线自测 + 保留机器级安装兜底】(2026-10-11 第三次实测修正):
|
||
# `33a4aa4`(DTU_* 变量改造)把 Python 探测内联进本文件,并把候选**收窄成两个**
|
||
# (PATH python + 仓库 .venv)——而 runner 上这两个都是死路:
|
||
# · SYSTEM 账号的 PATH python = `C:\msys64\ucrt64\bin\python.exe`,**没有** pre-commit;
|
||
# · 仓库 `.venv` 已 gitignore,runner 的全新工作区里**根本不存在**。
|
||
# 唯一可用的机器级解释器(`C:\ProgramData\anaconda3\python.exe`)不再被探测,而文档
|
||
# 要求的机器级 `DTU_PRECOMMIT_PYTHON` 从未设置(Machine 作用域一个 DTU_* 都没有)
|
||
# → run 14/15 的 L0 **全部死在"环境自检",一个钩子都没跑**。cppcheck 同理:
|
||
# 旧写法"不在 PATH 就把 C:\Program Files\Cppcheck 加进 PATH"的兜底也被删了。
|
||
# **故探测逻辑搬进 `scripts/ci_find_python.ps1`**(两个 job 共用、可离线跑、失败时
|
||
# 打印全部候选),并恢复常见机器级安装位置兜底;`DTU_*` 仍是最高优先级、
|
||
# 且"显式设置就必须有效、不静默回退"的语义不变。
|
||
#
|
||
# 🔴 gitlint 属 commit-msg stage,`--all-files` **永远跑不到它**(2026-10-11 审计):
|
||
# `.pre-commit-config.yaml` 里 gitlint 那一条**永远不进 CI 报告**(报告数 = 定义数 − 1;
|
||
# 2026-10-12 实测 `master` 18 定义 / 17 报告、`v2-on-ui` 26 定义 / 25 报告)——
|
||
# 缺的正是 gitlint:`stages: [commit-msg]` 写在 **gitlint 上游的 .pre-commit-hooks.yaml**
|
||
# 里(项目配置里 grep 不到这一行),而 `pre-commit run --all-files` 只跑 pre-commit stage。
|
||
# 即 AGENTS.md 的"单行中文标题 ≤100 字符"门禁**本地有、CI 无**,
|
||
# 且表现为"全绿"而非失败,所以一直没人发现。现由 `scripts/ci_gitlint_range.ps1`
|
||
# 逐条校验本次 push/PR 引入的提交(消息文件必须写在**仓库内**且 **UTF-8 无 BOM**:
|
||
# 跨盘符会被 pre-commit 拒绝 `path is on mount 'C:', start on mount 'D:'`,
|
||
# BOM 会占掉标题 1 个字符、扭曲 100 上限)。
|
||
#
|
||
# 🔴 写 `GITHUB_ENV` / `GITHUB_PATH` / `GITHUB_OUTPUT` 必须显式 `-Encoding ascii`
|
||
# (2026-10-11 实测):PS 5.1 的 `Out-File -Append` **默认 UTF-16LE**——往 runner
|
||
# 用 UTF-8 建的同一个文件里追加,落盘就是 `B\x00B\x00B\x00=\x001\x00` 这种带 NUL 的行
|
||
# (实测字节:`42 00 42 00 42 00 3D 00 31 00`)。本文件写入的这些键值全是 ASCII,
|
||
# 故统一显式 ascii;`GITHUB_STEP_SUMMARY` 是 Markdown,保持 utf8。
|
||
#
|
||
# ⚠️ 前置(部署到 Gitea 时需确认):
|
||
# - 该 Gitea 实例已启用 Actions:`[actions] ENABLED = true`(site admin 配置)
|
||
# - 已注册一个 **Windows** runner(labels 含 `windows`),且装有:
|
||
# Python 3.x、cppcheck、pre-commit、MSVC(VS2019/2022 的 C++ 工具链 + Windows SDK)
|
||
# - 缺 MSVC 时 L0.5 步骤会**降级为提示并跳过**(不硬失败),但会打印醒目 WARNING
|
||
#
|
||
# 🔧 runner 机器级环境变量(可选项覆盖;runner 以 SYSTEM 服务运行,用户级变量不可见):
|
||
# DTU_PRECOMMIT_PYTHON / DTU_CPPCHECK / DTU_MSVC_CL —— 未设置时按 PATH / 标准 VS 布局自动发现。
|
||
# 变量显式设置但值无效时 job 直接报变量名并失败(不静默回退)。配置方法见
|
||
# docs/环境与部署/本地工具路径环境变量方案.md §5.3。
|
||
#
|
||
# 本地等价命令(与 CI 同一套判定,见 scripts/ci_local.py):
|
||
# & '.venv\Scripts\python.exe' scripts/ci_local.py
|
||
|
||
name: CI
|
||
|
||
on:
|
||
push:
|
||
branches: ['**']
|
||
paths-ignore:
|
||
- 'docs/**'
|
||
- '**/*.md'
|
||
- '.workbuddy-ai/**'
|
||
pull_request:
|
||
paths-ignore:
|
||
- 'docs/**'
|
||
- '**/*.md'
|
||
- '.workbuddy-ai/**'
|
||
workflow_dispatch:
|
||
|
||
# 同一分支的旧运行自动取消,省 runner
|
||
concurrency:
|
||
group: ci-${{ github.ref }}
|
||
cancel-in-progress: true
|
||
|
||
jobs:
|
||
# ------------------------------------------------------------------
|
||
# L0:静态门禁(pre-commit 钩子全集)
|
||
# ------------------------------------------------------------------
|
||
l0-gates:
|
||
name: L0 静态门禁 (pre-commit)
|
||
runs-on: windows
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
with:
|
||
# 钩子要对比基线与历史(doc-links 等读工作区即可,但 gitlint/cubemx 需完整历史)
|
||
fetch-depth: 0
|
||
|
||
- name: 环境自检(Python / cppcheck / pre-commit)
|
||
shell: powershell
|
||
run: |
|
||
# NOTE: keep every run: block pure ASCII. The runner writes this block
|
||
# to a BOM-less .ps1 executed by Windows PowerShell 5.1, which decodes
|
||
# it as GBK -- non-ASCII bytes there break string quoting.
|
||
# --- pick a Python that actually has pre-commit -------------------
|
||
# Discovery lives in scripts/ci_find_python.ps1 so that
|
||
# * both jobs share ONE implementation,
|
||
# * it can be run (and its failure list inspected) offline,
|
||
# * the well-known machine-wide install locations stay probed.
|
||
# The previous inline 2-candidate probe is what killed every L0 run
|
||
# since 33a4aa4 -- see the file header.
|
||
#
|
||
# The script prints diagnostics with Write-Host and emits ONLY the
|
||
# chosen path on the success stream, so capturing it is exact.
|
||
# It also appends PYTHON=<path> to GITHUB_ENV (as ASCII) for later
|
||
# steps, which is why $env:PYTHON works in the steps below.
|
||
$py = & './scripts/ci_find_python.ps1' -NeedPreCommit -WriteEnv
|
||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||
if (-not $py) {
|
||
Write-Host "::error::ci_find_python.ps1 succeeded but returned no path"
|
||
exit 2
|
||
}
|
||
$py = "$py".Trim()
|
||
|
||
# --- cppcheck -----------------------------------------------------
|
||
# Order: DTU_CPPCHECK (explicit; invalid = hard error, no fallback)
|
||
# -> PATH -> the standard install directory.
|
||
#
|
||
# The directory fallback is NOT cosmetic: the runner service runs as
|
||
# SYSTEM, whose PATH has no Cppcheck entry (a USER-scope PATH entry is
|
||
# invisible to it), and no machine-level DTU_CPPCHECK is set on a
|
||
# stock runner. Dropping this fallback in 33a4aa4 is what made L0 die
|
||
# on cppcheck as well, right after the Python probe had been fixed.
|
||
$found = $null
|
||
$envCpp = $env:DTU_CPPCHECK
|
||
if ($envCpp) {
|
||
if (-not (Test-Path $envCpp)) {
|
||
Write-Host "::error::DTU_CPPCHECK is set but does not exist: $envCpp"
|
||
Write-Host "::error::fix the variable (full path to cppcheck.exe), or unset it to use auto-discovery"
|
||
exit 1
|
||
}
|
||
$found = $envCpp
|
||
Write-Host "using cppcheck from DTU_CPPCHECK: $found"
|
||
} else {
|
||
$cmd = Get-Command cppcheck -ErrorAction SilentlyContinue
|
||
if ($cmd) {
|
||
$found = $cmd.Source
|
||
Write-Host "found cppcheck on PATH: $found"
|
||
} else {
|
||
$pf = $env:ProgramFiles
|
||
$pf86 = ${env:ProgramFiles(x86)}
|
||
if (-not $pf) { $pf = 'C:\Program Files' }
|
||
if (-not $pf86) { $pf86 = 'C:\Program Files (x86)' }
|
||
foreach ($cand in @(
|
||
(Join-Path $pf 'Cppcheck\cppcheck.exe'),
|
||
(Join-Path $pf86 'Cppcheck\cppcheck.exe'))) {
|
||
if (Test-Path $cand) { $found = $cand; break }
|
||
}
|
||
}
|
||
}
|
||
if (-not $found) {
|
||
Write-Host "::error::runner missing cppcheck (not on SYSTEM PATH, not in the standard install dir)"
|
||
Write-Host "::error::set a machine-level DTU_CPPCHECK (see scripts/ci_runner_bootstrap.ps1 -SetEnv) or add its directory to the machine PATH, then re-run"
|
||
exit 1
|
||
}
|
||
$cppDir = Split-Path -Parent $found
|
||
$env:Path = "$env:Path;$cppDir"
|
||
# ASCII on purpose: Out-File's PS 5.1 default is UTF-16LE, and other
|
||
# steps append to this same file -- mixed encodings corrupt it.
|
||
$cppDir | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding ascii
|
||
& $found --version 2>&1 | Out-Host
|
||
|
||
Write-Host "runner environment check passed"
|
||
|
||
- name: 安装 pre-commit 钩子到本仓库
|
||
shell: powershell
|
||
run: "& $env:PYTHON -s -m pre_commit install --install-hooks"
|
||
|
||
- name: 提交信息门禁(gitlint,commit-msg stage)
|
||
shell: powershell
|
||
env:
|
||
DTU_BASE_SHA: ${{ github.event.before }}
|
||
DTU_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||
DTU_HEAD_SHA: ${{ github.sha }}
|
||
run: |
|
||
# Pure ASCII only (see file header).
|
||
#
|
||
# WHY THIS STEP EXISTS: gitlint is a `commit-msg` stage hook, and
|
||
# `pre-commit run --all-files` only runs the `pre-commit` stage -- so
|
||
# the commit-message rule was enforced locally and NEVER here, while
|
||
# the job still reported green: the reported hook count is always
|
||
# (defined count - 1), so a silently missing hook is invisible.
|
||
# Recount that difference whenever hooks are added or removed.
|
||
# pre-commit has no notion of "the commits of this push", so the
|
||
# script feeds each commit message to the hook one at a time.
|
||
#
|
||
# Range: PR -> base..head; push -> event.before..sha, falling back to
|
||
# the newest commits when `before` is all-zero (new branch) or
|
||
# unreachable (force push). Merge commits are skipped (git writes them).
|
||
$base = $env:DTU_PR_BASE_SHA
|
||
if (-not $base) { $base = $env:DTU_BASE_SHA }
|
||
& './scripts/ci_gitlint_range.ps1' -BaseSha $base -HeadSha $env:DTU_HEAD_SHA `
|
||
-MaxCommits 50 -PythonExe $env:PYTHON
|
||
if ($LASTEXITCODE -ne 0) {
|
||
Write-Host "::error::commit message gate failed (gitlint) -- see the per-commit output above"
|
||
exit $LASTEXITCODE
|
||
}
|
||
|
||
- name: 决定跳过哪些钩子(Keil 依赖)
|
||
id: skip
|
||
shell: powershell
|
||
run: |
|
||
# mem-numbers / mem-budget fact source is the Keil build artifact .map
|
||
# (gitignored). No .map => those hooks cannot run => skip them EXPLICITLY.
|
||
#
|
||
# A-5 (2026-10-25): two honesty fixes here.
|
||
# 1) mem-budget used NOT to be listed, yet it self-skips inside the script
|
||
# when .map is missing (exit 0) -- so on a Keil-less runner it reported
|
||
# "Passed" while checking nothing. A gate that fakes green is worse than
|
||
# a gate that is visibly skipped: skip it here so the report says Skipped.
|
||
# 2) "skipped" alone is still the A-5 blind spot, so the compensation is a
|
||
# real step, not only a warning: see the "size gate" step at the end of
|
||
# this job (scripts/check_size_gate.py --ci, no Keil needed: attestation
|
||
# parity + 92% re-check on the recorded numbers). Boundary and rationale
|
||
# live in that script docstring (single source of truth).
|
||
$map = Get-ChildItem -Path 'MDK-ARM' -Recurse -Filter '*.map' -ErrorAction SilentlyContinue
|
||
if ($map) {
|
||
Write-Host "found .map: $($map[0].FullName) -- size hooks run normally"
|
||
"value=" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||
"sizegate=on" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||
"> Volume gates (mem-numbers, mem-budget): RAN (.map present)" |
|
||
Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
|
||
} else {
|
||
Write-Host "::warning::no .map found (no Keil build) -- SKIPPING mem-numbers and mem-budget, so absolute Flash/RAM numbers are NOT recomputed here."
|
||
Write-Host "::warning::that gap is no longer silent: the final step of this job runs scripts/check_size_gate.py --ci (A-5 forced path: attestation parity + 92% budget re-check). Locally: scripts/ci_local.py step L0c."
|
||
"value=mem-numbers,mem-budget" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||
"sizegate=off" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||
"> :warning: Volume gates (mem-numbers, mem-budget): **NOT RUN** -- no `.map` (no Keil build); compensated by the size-gate step below." |
|
||
Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
|
||
}
|
||
|
||
- name: 运行 L0 门禁(--all-files)
|
||
shell: powershell
|
||
env:
|
||
SKIP: ${{ steps.skip.outputs.value }}
|
||
run: |
|
||
# Pure ASCII only (see file header). Capture output to an artifact file
|
||
# AND to the console (Tee-Object), so a failed run is debuggable from the
|
||
# Gitea UI without inspecting runner disk.
|
||
#
|
||
# CAUTION: the runner injects $ErrorActionPreference='Stop'. With `2>&1 |`,
|
||
# a native command's stderr becomes an ErrorRecord -> under 'Stop' that
|
||
# would ABORT the pipeline. Relax the preference around the pipeline and
|
||
# restore it afterwards (same pattern as the env-check step above).
|
||
New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null
|
||
$log = 'ci-artifacts\l0-pre-commit.log'
|
||
if ($env:SKIP) { Write-Host "SKIP=$env:SKIP" }
|
||
$saved = $ErrorActionPreference
|
||
$ErrorActionPreference = 'Continue'
|
||
& $env:PYTHON -s -m pre_commit run --all-files --show-diff-on-failure 2>&1 |
|
||
Tee-Object -FilePath $log
|
||
$rc = $LASTEXITCODE
|
||
$ErrorActionPreference = $saved
|
||
|
||
# Summarize hook results: count Passed / Failed / Skipped lines.
|
||
$lines = Get-Content $log -ErrorAction SilentlyContinue
|
||
$passed = @($lines | Select-String -Pattern 'Passed$').Count
|
||
$failed = @($lines | Select-String -Pattern 'Failed$').Count
|
||
$skipd = @($lines | Select-String -Pattern 'Skipped$').Count
|
||
$summary = "L0: pre-commit hooks -- passed=$passed failed=$failed skipped=$skipd (log: $log)"
|
||
Write-Host "::notice::$summary"
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### L0 static gates" -Encoding utf8
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary -Encoding utf8
|
||
|
||
if ($rc -ne 0) {
|
||
Write-Host "::error::L0 static gates failed (see output above and $log)"
|
||
exit $rc
|
||
}
|
||
|
||
- name: 归档 L0 日志(供下载/排障)
|
||
if: always()
|
||
shell: powershell
|
||
run: |
|
||
# S3: zero-dependency artifact archival. We do NOT use
|
||
# actions/upload-artifact (inner networks may not fetch outer actions);
|
||
# instead we keep the log on the runner AND surface its path + tail in
|
||
# the step summary, so the UI is self-sufficient for triage.
|
||
if (Test-Path 'ci-artifacts\l0-pre-commit.log') {
|
||
$size = (Get-Item 'ci-artifacts\l0-pre-commit.log').Length
|
||
Write-Host "archived: ci-artifacts/l0-pre-commit.log ($size bytes)"
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "" -Encoding utf8
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "Archived log: ``ci-artifacts/l0-pre-commit.log`` ($size bytes on runner)" -Encoding utf8
|
||
}
|
||
Write-Host "::notice::L0 artifacts kept on runner at: ci-artifacts\l0-pre-commit.log"
|
||
|
||
# ------------------------------------------------------------
|
||
# A-5 残余的强制体积门禁(2026-10-25):不需要 Keil,也不需要 .map
|
||
# 放在 L0 作业**最后**且 if: always()——L0 失败时这条结论同样要看得到,
|
||
# 而它自己失败会让整个作业变红(这正是"强制"的含义)。
|
||
- name: 体积门禁(A-5 强制路径:同码凭证 + 92% 红线,无需 Keil)
|
||
if: always()
|
||
shell: powershell
|
||
env:
|
||
GATE_REF: ${{ github.ref }}
|
||
GATE_EVENT: ${{ github.event_name }}
|
||
run: |
|
||
# Pure ASCII only (see file header).
|
||
#
|
||
# WHY THIS STEP EXISTS: mem-numbers / mem-budget read the Keil .map and
|
||
# self-skip (exit 0) without it, so "not measured" used to look like
|
||
# "passed" -- that is exactly the A-5 blind spot ("CI all green does not
|
||
# mean the size budget held"). Declaring it (S14) did not close it.
|
||
#
|
||
# This step enforces a check that needs NO Keil and NO .map:
|
||
# * parity -- scripts/size_attestation.json (a committed attestation
|
||
# that --stamp refuses to write unless the .map is present, newer
|
||
# than every source file in scope, and inside budget) must
|
||
# fingerprint-match THIS checked-out tree => this exact code was
|
||
# linked for real at least once;
|
||
# * budget -- the region percentages that measurement recorded must
|
||
# stay within the same 92% line mem-budget uses.
|
||
#
|
||
# Enforcement point = trunk push, the only ref guaranteed to be the code
|
||
# we ship. Task branches / WIP pull requests run --warn-only instead: the
|
||
# code there is still moving, and blocking it would push every lane to
|
||
# edit the same scripts/ ledger file at the same time.
|
||
New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null
|
||
$log = 'ci-artifacts\size-gate.log'
|
||
if (-not $env:PYTHON) {
|
||
# The environment probe step already failed in that case, so the job is
|
||
# red anyway; do not mask that with a second, unrelated error.
|
||
Write-Host "::warning::PYTHON unresolved (env step failed) -- size gate cannot run"
|
||
exit 0
|
||
}
|
||
$trunks = @('refs/heads/v2-on-ui', 'refs/heads/master', 'refs/heads/main')
|
||
$enforce = ($env:GATE_EVENT -eq 'push') -and ($trunks -contains $env:GATE_REF)
|
||
if ($enforce) {
|
||
$mode = @()
|
||
$label = 'ENFORCING (trunk push)'
|
||
} else {
|
||
$mode = @('--warn-only')
|
||
$label = 'advisory (non-trunk event)'
|
||
}
|
||
$saved = $ErrorActionPreference
|
||
$ErrorActionPreference = 'Continue'
|
||
& $env:PYTHON -s scripts/check_size_gate.py --ci @mode 2>&1 |
|
||
Tee-Object -FilePath $log
|
||
$rc = $LASTEXITCODE
|
||
$ErrorActionPreference = $saved
|
||
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "" -Encoding utf8
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### Volume gate (A-5 forced path)" -Encoding utf8
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "size gate: rc=$rc mode=$label (attestation parity + 92% re-check; no Keil)" -Encoding utf8
|
||
Write-Host "::notice::size gate: rc=$rc mode=$label (log: $log)"
|
||
if ($rc -ne 0) {
|
||
Write-Host "::error::volume gate FAILED: this tree has no matching real Keil measurement, or the measured regions exceed the 92% budget."
|
||
Write-Host "::error::fix: run the Keil build locally (0 errors 0 warnings), then 'python scripts/ram_baseline.py --update', and commit scripts/size_attestation.json (scripts/ram_baseline.tsv is refreshed by the same command). Rationale/boundary: scripts/check_size_gate.py docstring."
|
||
exit $rc
|
||
}
|
||
|
||
# ------------------------------------------------------------------
|
||
# L0.5:主机侧纯逻辑单测(零硬件;缺 MSVC 则跳过)
|
||
# ------------------------------------------------------------------
|
||
l05-host-unit-tests:
|
||
name: L0.5 主机侧单测 (MSVC)
|
||
runs-on: windows
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: 探测 MSVC(VS BuildTools / Community 等)
|
||
id: msvc
|
||
shell: powershell
|
||
run: |
|
||
# MSVC discovery per the DTU_* plan, kept in sync with
|
||
# test/host/common/unit_test_harness.py:
|
||
# DTU_MSVC_CL (explicit x64 cl.exe; invalid = hard error)
|
||
# -> auto-probe standard VS roots derived from %ProgramFiles%
|
||
# / %ProgramFiles(x86)% (2019 / 2022 / 2026, all editions)
|
||
$found = $false
|
||
$envCl = $env:DTU_MSVC_CL
|
||
if ($envCl) {
|
||
if (-not (Test-Path $envCl)) {
|
||
Write-Host "::error::DTU_MSVC_CL is set but does not exist: $envCl"
|
||
exit 1
|
||
}
|
||
if ($envCl -notlike '*bin\Hostx64\x64\cl.exe') {
|
||
Write-Host "::error::DTU_MSVC_CL must be the x64 Host compiler (path ending bin\Hostx64\x64\cl.exe): $envCl"
|
||
exit 1
|
||
}
|
||
$found = $true
|
||
Write-Host "using cl.exe from DTU_MSVC_CL: $envCl"
|
||
} else {
|
||
$pf = ${env:ProgramFiles}
|
||
$pf86 = ${env:ProgramFiles(x86)}
|
||
if (-not $pf) { $pf = 'C:\Program Files' }
|
||
if (-not $pf86) { $pf86 = 'C:\Program Files (x86)' }
|
||
$roots = @(
|
||
(Join-Path $pf86 'Microsoft Visual Studio\2019\BuildTools'),
|
||
(Join-Path $pf86 'Microsoft Visual Studio\2019\Community'),
|
||
(Join-Path $pf86 'Microsoft Visual Studio\2019\Professional'),
|
||
(Join-Path $pf 'Microsoft Visual Studio\2022\BuildTools'),
|
||
(Join-Path $pf 'Microsoft Visual Studio\2022\Community'),
|
||
(Join-Path $pf 'Microsoft Visual Studio\2022\Professional'),
|
||
(Join-Path $pf 'Microsoft Visual Studio\2022\Enterprise'),
|
||
(Join-Path $pf 'Microsoft Visual Studio\2026\BuildTools'),
|
||
(Join-Path $pf 'Microsoft Visual Studio\2026\Community'),
|
||
(Join-Path $pf 'Microsoft Visual Studio\18\BuildTools'),
|
||
(Join-Path $pf 'Microsoft Visual Studio\18\Community'),
|
||
(Join-Path $pf 'Microsoft Visual Studio\18\Professional'),
|
||
(Join-Path $pf 'Microsoft Visual Studio\18\Enterprise')
|
||
)
|
||
foreach ($r in $roots) {
|
||
if (Test-Path $r) {
|
||
$cl = Get-ChildItem -Path $r -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.FullName -like '*Hostx64\x64*' } | Select-Object -First 1
|
||
if ($cl) { $found = $true; Write-Host "found cl.exe: $($cl.FullName)"; break }
|
||
}
|
||
}
|
||
}
|
||
if ($found) {
|
||
"value=yes" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||
} else {
|
||
Write-Host "::warning::runner has no MSVC (VS C++ toolchain) -- skipping L0.5. Install VS BuildTools and re-run."
|
||
"value=no" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||
}
|
||
|
||
- name: 运行主机侧单测
|
||
id: l05
|
||
if: steps.msvc.outputs.value == 'yes'
|
||
shell: powershell
|
||
run: |
|
||
# Resolve a working Python with the SAME shared probe as L0 (no
|
||
# -NeedPreCommit: these harnesses are stdlib-only). The old inline
|
||
# probe here had the same 2-candidate blind spot as L0 -- it only
|
||
# survived by luck, because the stdlib tests also run on the msys64
|
||
# python that happens to be on SYSTEM's PATH.
|
||
$py = & './scripts/ci_find_python.ps1' -WriteEnv
|
||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||
if (-not $py) {
|
||
Write-Host "::error::ci_find_python.ps1 succeeded but returned no path"
|
||
exit 2
|
||
}
|
||
$py = "$py".Trim()
|
||
Write-Host "using python: $py"
|
||
|
||
New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null
|
||
|
||
# Iterate test/host/*/run_unit_test.py (each module ships its own runner via common harness)
|
||
$runners = Get-ChildItem -Path 'test/host' -Recurse -Filter 'run_unit_test.py' |
|
||
Sort-Object FullName
|
||
if (-not $runners) {
|
||
Write-Host "::warning::no test/host/**/run_unit_test.py found -- skipping"
|
||
exit 0
|
||
}
|
||
$fail = 0
|
||
$runnerCount = 0
|
||
$sumPass = 0
|
||
$sumTotal = 0
|
||
# Relax preference around each `2>&1 | Tee-Object` pipeline (native stderr
|
||
# becomes ErrorRecord -> 'Stop' would abort). See L0 step for details.
|
||
$savedPref = $ErrorActionPreference
|
||
foreach ($r in $runners) {
|
||
$runnerCount++
|
||
$name = $r.Directory.Name
|
||
$log = "ci-artifacts\l05-$name.log"
|
||
Write-Host "================================"
|
||
Write-Host "run: $($r.FullName)"
|
||
Write-Host "================================"
|
||
$ErrorActionPreference = 'Continue'
|
||
& $py $r.FullName 2>&1 | Tee-Object -FilePath $log
|
||
$rc = $LASTEXITCODE
|
||
$ErrorActionPreference = $savedPref
|
||
|
||
# Parse the module's own summary line, e.g.
|
||
# "=== System Clock Tests: 100/100 PASSED (failed=0) ==="
|
||
$m = Select-String -Path $log -Pattern ': (\d+)/(\d+) PASSED \(failed=(\d+)\)' -ErrorAction SilentlyContinue |
|
||
Select-Object -Last 1
|
||
if ($m) {
|
||
$sumPass += [int]$m.Matches[0].Groups[1].Value
|
||
$sumTotal += [int]$m.Matches[0].Groups[2].Value
|
||
Write-Host ("::notice::{0}: {1}" -f $name, $m.Matches[0].Value.Trim())
|
||
} else {
|
||
Write-Host "::warning::$name produced no '=== ... N/M PASSED ===' summary line"
|
||
}
|
||
if ($rc -ne 0) {
|
||
Write-Host "::error::unit test failed: $($r.FullName) (exit $rc)"
|
||
$fail = 1
|
||
}
|
||
}
|
||
|
||
$agg = "L0.5: $runnerCount runner(s), $sumPass/$sumTotal assertions passed, fail=$fail"
|
||
Write-Host "::notice::$agg"
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### L0.5 host-side unit tests" -Encoding utf8
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $agg -Encoding utf8
|
||
"count=$runnerCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||
"agg=$agg" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||
exit $fail
|
||
|
||
- name: 归档 L0.5 日志(供下载/排障)
|
||
if: always()
|
||
shell: powershell
|
||
run: |
|
||
# S3: same zero-dependency archival as L0.
|
||
if (Test-Path 'ci-artifacts') {
|
||
$logs = Get-ChildItem -Path 'ci-artifacts' -Filter '*.log' -ErrorAction SilentlyContinue
|
||
foreach ($f in $logs) {
|
||
Write-Host "archived: $($f.FullName) ($($f.Length) bytes)"
|
||
}
|
||
$names = ($logs | ForEach-Object { '``' + $_.Name + '``' }) -join ', '
|
||
if ($names) {
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "" -Encoding utf8
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "Archived logs on runner: $names" -Encoding utf8
|
||
}
|
||
} else {
|
||
Write-Host "no ci-artifacts directory (L0.5 skipped or failed early)"
|
||
}
|
||
|
||
- name: L0.5 跳过说明
|
||
if: steps.msvc.outputs.value == 'no'
|
||
shell: powershell
|
||
run: |
|
||
Write-Host "L0.5 skipped: runner has no MSVC."
|
||
Write-Host "Local equivalent: & '.venv\Scripts\python.exe' scripts/ci_local.py"
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### L0.5 host-side unit tests" -Encoding utf8
|
||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ":warning: **NOT RUN** -- runner has no MSVC (VS C++ toolchain)." -Encoding utf8
|
||
exit 0
|