# Gitea Actions CI —— STM32F4-Base 持续集成 # # 覆盖范围(**只做不依赖硬件的层**,见 docs/测试说明/测试开发标准流程.md §1/§9): # 静态门禁 static = pre-commit 全部钩子(等价本地 `pre-commit run --all-files`) # + 手动阶段三根(覆盖率总闸 / 全量 cppcheck / test-cppcheck,`--hook-stage manual`; # 它们在 .pre-commit-config.yaml 里带 stages: [manual],提交期不跑,由本 job 强制) # 主机侧纯逻辑单测 host-unit = test/host/**(MSVC 离线编译,零硬件) # # ⛔ 板端测试套件(test/board/,需真机 + ST-LINK)/ 主机侧回归(@test 闭环)/ 人工目检 # **不上 CI** —— 需要开发板与烧录器,只能在本地跑。 # # 明确不做的两件事(都是刻意为之): # 1. `@build`(Keil UV4)—— 需本机 Keil 授权,runner 上不具备;且静态门禁的 cppcheck + # 代码规范已覆盖"编译前的静态问题",真编译仍是开发者的本地红线(AGENTS.md)。 # 2. `mem-numbers` / `mem-budget` 两根钩子 —— 事实源都是 `MDK-ARM/**/*.map`(Keil # 构建产物、已 gitignore),无 `@build` 即无 `.map`。故本 workflow 在**检测不到 # .map 时自动跳过它们**(见下方 "决定跳过哪些钩子" 步骤),有 .map 的 runner 照常跑。 # # 🔴 体积门禁:盲区已关闭为**强制路径**(2026-10-25 起;原先只做到"声明") # 跳过钩子只解释了"runner 没有 Keil",没解决**"没跑 = 通过"**:钩子在无 .map 时 # 自适应 SKIP 并返回 0,于是 CI 全绿仍可能被读成"体积也守住了"。现由静态门禁作业**最后 # 一步** `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 每次改写且不入库)不在指纹范围内。 # # 产物存档与结果可见性(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 的静态门禁 **全部死在"环境自检",一个钩子都没跑**。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 时主机侧单测步骤会**降级为提示并跳过**(不硬失败),但会打印醒目 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: # ------------------------------------------------------------------ # 静态门禁(pre-commit 钩子全集) # ------------------------------------------------------------------ static-gates: name: 静态门禁 (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 static-gate 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= 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 the static-gate job 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. # # 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 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 (forced path: attestation parity + 92% budget re-check). Locally: scripts/ci_local.py size step." "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: 运行静态门禁(--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\static-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 = "static: pre-commit hooks -- passed=$passed failed=$failed skipped=$skipd (log: $log)" Write-Host "::notice::$summary" Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### 静态门禁 (static gates)" -Encoding utf8 Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary -Encoding utf8 if ($rc -ne 0) { Write-Host "::error::static gates failed (see output above and $log)" exit $rc } # ------------------------------------------------------------ # 手动阶段门禁(2026-10-28 用户裁定):主机单测覆盖率总闸 + 全量 cppcheck。 # 这三根钩子在 .pre-commit-config.yaml 里带 `stages: [manual]`,**不在 git commit 时跑** # (实测合计 ~165 s = 一次带代码提交耗时的三分之二);提交期只跑快的那些,全量扫描由本 job 强制。 # 逐根调用而不是一条命令:`pre_commit run ` 不支持多 id(实测 argparse 报 # "unrecognized arguments")。 - name: 手动阶段门禁(覆盖率总闸 + 全量 cppcheck) if: always() shell: powershell run: | # Pure ASCII only (see file header). Same $ErrorActionPreference dance as the # static-gate step above: the runner injects 'Stop', and '2>&1 |' turns a # native command's stderr into an ErrorRecord that would abort the pipeline. New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null $log = 'ci-artifacts/manual-stage.log' if (Test-Path $log) { Remove-Item $log -Force } $rc = 0 $hooks = @('cppcheck', 'test-cppcheck', 'test-coverage') foreach ($h in $hooks) { Write-Host "== manual hook: $h ==" $saved = $ErrorActionPreference $ErrorActionPreference = 'Continue' & $env:PYTHON -s -m pre_commit run --all-files --hook-stage manual $h 2>&1 | Tee-Object -FilePath $log -Append $one = $LASTEXITCODE $ErrorActionPreference = $saved Write-Host "manual hook $h rc=$one" if ($one -ne 0) { $rc = $one } } $lines = Get-Content $log -ErrorAction SilentlyContinue $passed = @($lines | Select-String -Pattern 'Passed$').Count $failed = @($lines | Select-String -Pattern 'Failed$').Count $summary = "manual-stage: coverage + cppcheck -- passed=$passed failed=$failed (log: $log)" Write-Host "::notice::$summary" Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### manual stage gates (coverage + cppcheck)" -Encoding utf8 Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary -Encoding utf8 if ($rc -ne 0) { Write-Host "::error::manual-stage gates failed (see output above and $log)" exit $rc } - name: 归档静态门禁日志(供下载/排障) if: always() shell: powershell run: | # Result visibility: 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\static-pre-commit.log') { $size = (Get-Item 'ci-artifacts\static-pre-commit.log').Length Write-Host "archived: ci-artifacts/static-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/static-pre-commit.log`` ($size bytes on runner)" -Encoding utf8 } if (Test-Path 'ci-artifacts\manual-stage.log') { $msize = (Get-Item 'ci-artifacts\manual-stage.log').Length Write-Host "archived: ci-artifacts/manual-stage.log ($msize bytes)" Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "Archived log: ``ci-artifacts/manual-stage.log`` ($msize bytes on runner)" -Encoding utf8 } Write-Host "::notice::static-gate artifacts kept on runner at: ci-artifacts\ (static-pre-commit.log, manual-stage.log)" # ------------------------------------------------------------ # 强制体积门禁(2026-10-25):不需要 Keil,也不需要 .map # 放在静态门禁作业**最后**且 if: always()——静态门禁失败时这条结论同样要看得到, # 而它自己失败会让整个作业变红(这正是"强制"的含义)。 - name: 体积门禁(强制路径:同码凭证 + 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 blind spot ("CI all green does not # mean the size budget held"). Merely declaring it 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 (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 } # ------------------------------------------------------------------ # 主机侧纯逻辑单测(零硬件;缺 MSVC 则跳过) # ------------------------------------------------------------------ host-unit-tests: name: 主机侧单测 (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 host-side unit tests. 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 the static-gate job (no # -NeedPreCommit: these harnesses are stdlib-only). The old inline # probe here had the same 2-candidate blind spot as the static-gate job -- 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 the static-gate steps for details. $savedPref = $ErrorActionPreference foreach ($r in $runners) { $runnerCount++ $name = $r.Directory.Name $log = "ci-artifacts\host-unit-$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 = "host-unit: $runnerCount runner(s), $sumPass/$sumTotal assertions passed, fail=$fail" Write-Host "::notice::$agg" Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### 主机侧纯逻辑单测 (host-unit)" -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: 归档主机侧单测日志(供下载/排障) if: always() shell: powershell run: | # Result visibility: same zero-dependency archival as the static-gate job. 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 (host-side unit tests skipped or failed early)" } - name: 主机侧单测跳过说明 if: steps.msvc.outputs.value == 'no' shell: powershell run: | Write-Host "host-side unit tests 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 "### 主机侧纯逻辑单测 (host-unit)" -Encoding utf8 Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ":warning: **NOT RUN** -- runner has no MSVC (VS C++ toolchain)." -Encoding utf8 exit 0