Files
STM32F4-Base/.gitea/workflows/ci.yml
T
wandering1 ae80bb8e34
CI / L0 静态门禁 (pre-commit) (push) Failing after 6m43s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 2m39s
fix(ci): run 块按点源语义加固——原生 stderr 变终止性错误曾致两 job 3 秒亡
run 44(本分支上一次推送)把上一版原生检出打回原形:L0 用了 3 s、L0.5 用了
2 s 双双失败,日志里是 error: No such remote 'origin' 被包成 NativeCommandError
后整步终止。根因在我上一版:runner 执行 run: 块的方式是 powershell -command
点源那个文件,会话继承的 $ErrorActionPreference 是 Stop,于是原生程序往 stderr
写的任意一行都会被 PS 5.1 包成终止性错误。我写的 git remote get-url origin
2>$null 正是这一类,而 2>$null 不算摊平,反而让它先把整步炸掉。

修法两件事一起做:块首显式 $ErrorActionPreference = Continue(不依赖继承);
所有 git 调用走同一个漏斗 Invoke-DtuGit,把 stderr 摊平成字符串再决定是否打印。
探针顺手换成 git config --get remote.origin.url:远端不存在时它只返回非零码、
不往 stderr 写,从源头不再触发这类错误。

更阴的一条教训是我的测法本身。第一版复现器用 -File 包一层点源,结果四条失败
路径全测成 rc=0。用最小矩阵把语义钉死后:exit 1 写在被点源的文件里时,-File
直跑 = 1;-File 包一层点源 = 0 且继续执行后续语句;-Command 点源(runner 的真
实用法)= 1。也就是说只要我一直用 -File 测,检出失败也会一路伪装成绿,这个洞
比上面那个 bug 更坏。现在统一用 runner 的真实语义测:powershell -NoProfile
-Command 点源块体文件,且前置 $ErrorActionPreference = Stop。

换测法后的实测(全部跑在从 ci.yml 抽出的那份字节上,不是另抄的副本):
- 点源 + Stop、全新工作区:ExitCode=0、checkout ok: c1e7e8c / commits=669、
  dirty_files=0、150.9 s(修复前同一模式就是 run 44 的 3 秒亡);
- DTU_REPO_SHA 空 / DTU_REPO_URL 空 / sha 不在远端 / 坏令牌:ExitCode 均 1,
  坏令牌 0.5 s 返回 unable to get password,不挂起、不烧 job 超时;
- 复用工作区:1.6 s,dirty_files=0,被忽略文件保留,未跟踪垃圾清除;
- 结构面:13 步、两 job 第 1 步逐字一致、uses: 计数 0、run 块非 ASCII 计数 0、
  无 BOM 无 CR;check yaml Passed;check_doc_links --all 180 篇、无法判定 0。

文档同笔落地(教训要跟它救的那段代码一起进历史):指南 §5.3 补 run 44 实录与
退出码矩阵、实测表补 G/H 两行、§7 补 job 几秒就亡一行、两份变更记录各加一行;
remote-ci-ops skill §10 补 run: 块的正确测法。另记一个运维事实:推到已存在
的远端分支会正常建 run(run 44 即如此,推送后约 4 分钟入库,我先前把轮询窗口
取得太短误判成没建),以后验 workflow 不再需要手动 dispatch 与服务凭据。
下一轮可能红的两处我先标出来,红了正是我们要的证据:SYSTEM 下的 ui-golden
(历史上败在 32 位 MSBuild 与 VCTargetsPath)与 test-coverage(缺
DTU_MS_COVERAGE,走自动发现)。
2026-10-27 12:52:56 +08:00

792 lines
48 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 照常跑。
#
# 🔴 体积门禁:盲区已关闭为**强制路径**(2026-10-25 起;原先只做到"声明")
# 跳过钩子只解释了"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 每次改写且不入库)不在指纹范围内。
#
# 产物存档与结果可见性(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:
- name: 检出源码(原生 git;不依赖 github.com 上的 action)
shell: powershell
env:
DTU_REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
DTU_REPO_REF: ${{ github.ref }}
DTU_REPO_SHA: ${{ github.sha }}
DTU_REPO_TOKEN: ${{ github.token }}
run: |
# Pure ASCII only (see file header).
#
# WHY NOT a "uses:" action: the runner resolves every uses: by contacting the
# actions URL during "Prepare all required actions" -- BEFORE the first step
# runs. On this intranet runner that call intermittently dies with a connect
# timeout: runs 36 (10-21) / 41 (10-25) / 42 (10-26) each burned ~45 s and
# produced ZERO hook output, while a manual curl to the same URL minutes later
# returned 200. Manual reachability is not CI health: the resolver runs as
# SYSTEM and the runner caches nothing across jobs. Gitea 1.25.4 also removed
# the custom DEFAULT_ACTIONS_URL knob, so the only fix this repo owns is to
# stop depending on the public resolver at all.
#
# KEEP IN SYNC with the same step in the other job. This logic deliberately
# does NOT live in scripts/*.ps1 (unlike the Python / MSVC probes): the file is
# not checked out yet at the moment it would be needed -- chicken and egg.
#
# $ErrorActionPreference is set to Continue EXPLICITLY. The runner executes a
# run: block by dot-sourcing it into a session, so an inherited Stop turns any
# raw native stderr line into a TERMINATING NativeCommandError. Measured
# 2026-10-27, run 44 / task-72: a benign "error: No such remote 'origin'" from
# a probe killed the whole job 3 seconds in, before any gate could run.
$ErrorActionPreference = 'Continue'
function Invoke-DtuGit {
# Single funnel for git. Two reasons it must be a funnel, not bare calls:
# 1) native stderr has to be flattened into plain strings (see above), and
# 2) $LASTEXITCODE has to stay readable by the caller afterwards.
# Returns Code + lines and lets the caller decide what to print.
param([string[]]$G)
$lines = & git @G 2>&1 | ForEach-Object { [string]$_ }
return [pscustomobject]@{ Code = $LASTEXITCODE; Lines = @($lines) }
}
if (-not $env:DTU_REPO_SHA) { Write-Host '::error::DTU_REPO_SHA is empty'; exit 1 }
if (-not $env:DTU_REPO_URL) { Write-Host '::error::DTU_REPO_URL is empty'; exit 1 }
# Never let a fetch wait for a human: the runner has no console, and a
# credential prompt would burn the whole job timeout while looking like a hung
# CI. Anonymous fetch works here (the repository is public; measured HTTP 200
# on /info/refs?service=git-upload-pack), so CI must not depend on a credential
# store it cannot answer.
$env:GIT_TERMINAL_PROMPT = '0'
$ws = (Get-Location).Path
Write-Host "workspace: $ws"
if (-not (Test-Path -LiteralPath (Join-Path $ws '.git'))) {
$r = Invoke-DtuGit -G @('init', '-q', '.')
if ($r.Code -ne 0) { Write-Host '::error::git init failed'; $r.Lines | ForEach-Object { Write-Host " $_" }; exit 1 }
}
# "git remote get-url" writes to stderr when the remote is absent -- that is
# the exact line that killed run 44. Ask the config instead: an absent key
# prints nothing and only sets a non-zero code, so nothing escapes as an error.
$probe = Invoke-DtuGit -G @('config', '--get', 'remote.origin.url')
$url = ($probe.Lines -join '').Trim()
if ($probe.Code -eq 0 -and $url) {
$r = Invoke-DtuGit -G @('remote', 'set-url', 'origin', $env:DTU_REPO_URL)
} else {
$r = Invoke-DtuGit -G @('remote', 'add', 'origin', $env:DTU_REPO_URL)
}
if ($r.Code -ne 0) { Write-Host '::error::configuring remote origin failed'; $r.Lines | ForEach-Object { Write-Host " $_" }; exit 1 }
# Full refspec, NOT --depth 1: the commit-message gate resolves base..head
# ranges and several gates diff against history, so a shallow clone turns them
# into "unknown revision" failures. Tags are skipped on purpose: this repository
# has zero tags and no script uses "git describe --tags" (measured 2026-10-27).
$r = Invoke-DtuGit -G @('config', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*')
if ($r.Code -ne 0) { Write-Host '::error::setting remote.origin.fetch failed'; exit 1 }
# The token, when the instance provides one, travels only as a per-invocation
# -c override: it never lands in .git/config and is never echoed in logs.
$auth = @('-c', 'credential.helper=', '-c', 'credential.interactive=false')
if ($env:DTU_REPO_TOKEN) {
# Same scheme actions/checkout uses against Gitea git-http: Basic with a
# placeholder user and the token as the password. (Bearer is the GitHub API
# style, not the git-http style.) Honest caveat: this branch CANNOT be
# verified here -- the repository is public, so an anonymous fetch succeeds
# and any token we fabricate locally only proves that a bad one is
# rejected. If the repository ever goes private this is the first line to
# check; the failure mode is a loud rc=1, never a silent fallback.
$pair = 'x-access-token:' + $env:DTU_REPO_TOKEN
$b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair))
$auth += @('-c', ('http.extraheader=AUTHORIZATION: Basic ' + $b64))
}
$f = Invoke-DtuGit -G ($auth + @('fetch', '--prune', '--no-tags', 'origin'))
if ($f.Code -ne 0) {
$f.Lines | ForEach-Object { Write-Host " $_" }
Write-Host ('::error::git fetch origin failed (exit ' + $f.Code + ')')
exit 1
}
# Some event refs (PR merge commits, refs outside refs/heads) are not covered
# by the branch refspec -- fall back to an explicit ref fetch, then INSIST the
# wanted SHA is really present rather than silently building the wrong tree.
$want = $env:DTU_REPO_SHA + '^{commit}'
$chk = Invoke-DtuGit -G @('cat-file', '-e', $want)
if ($chk.Code -ne 0) {
Write-Host 'sha not covered by the branch refspec -- fetching the event ref explicitly'
$f2 = Invoke-DtuGit -G ($auth + @('fetch', '--prune', '--no-tags', 'origin', $env:DTU_REPO_REF))
if ($f2.Code -ne 0) { $f2.Lines | ForEach-Object { Write-Host " $_" } }
$chk = Invoke-DtuGit -G @('cat-file', '-e', $want)
}
if ($chk.Code -ne 0) {
Write-Host ('::error::' + $env:DTU_REPO_SHA + ' is not present in the fetched history')
Write-Host ' (refusing to run the gates against a tree we did not verify)'
exit 1
}
$c = Invoke-DtuGit -G @('checkout', '-q', '--detach', $env:DTU_REPO_SHA)
if ($c.Code -ne 0) { $c.Lines | ForEach-Object { Write-Host " $_" }; Write-Host '::error::detached checkout failed'; exit 1 }
$rr = Invoke-DtuGit -G @('reset', '--hard', '-q', $env:DTU_REPO_SHA)
if ($rr.Code -ne 0) { Write-Host '::error::reset --hard failed'; exit 1 }
# -ffd, deliberately not -ffdx: -x would delete IGNORED files, and the runner
# reuses those across runs (pip / pre-commit caches live in the workspace).
$cl = Invoke-DtuGit -G @('clean', '-ffd', '-q')
if ($cl.Code -ne 0) { $cl.Lines | ForEach-Object { Write-Host " $_" }; Write-Host '::error::git clean failed'; exit 1 }
$head = (Invoke-DtuGit -G @('rev-parse', '--short', 'HEAD')).Lines -join ''
$ncommits = (Invoke-DtuGit -G @('rev-list', '--count', 'HEAD')).Lines -join ''
$dirty = @(Invoke-DtuGit -G @('status', '--porcelain')).Lines.Count
Write-Host ('checkout ok: ' + $head + ' | commits=' + $ncommits + ' | dirty_files=' + $dirty)
- 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.
#
# 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 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: |
# 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\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"
# ------------------------------------------------------------
# 强制体积门禁(2026-10-25):不需要 Keil,也不需要 .map
# 放在 L0 作业**最后**且 if: always()——L0 失败时这条结论同样要看得到,
# 而它自己失败会让整个作业变红(这正是"强制"的含义)。
- 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
}
# ------------------------------------------------------------------
# L0.5:主机侧纯逻辑单测(零硬件;缺 MSVC 则跳过)
# ------------------------------------------------------------------
l05-host-unit-tests:
name: L0.5 主机侧单测 (MSVC)
runs-on: windows
steps:
- name: 检出源码(原生 git;与 l0-gates 同一实现口径)
shell: powershell
env:
DTU_REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
DTU_REPO_REF: ${{ github.ref }}
DTU_REPO_SHA: ${{ github.sha }}
DTU_REPO_TOKEN: ${{ github.token }}
run: |
# Pure ASCII only (see file header).
#
# WHY NOT a "uses:" action: the runner resolves every uses: by contacting the
# actions URL during "Prepare all required actions" -- BEFORE the first step
# runs. On this intranet runner that call intermittently dies with a connect
# timeout: runs 36 (10-21) / 41 (10-25) / 42 (10-26) each burned ~45 s and
# produced ZERO hook output, while a manual curl to the same URL minutes later
# returned 200. Manual reachability is not CI health: the resolver runs as
# SYSTEM and the runner caches nothing across jobs. Gitea 1.25.4 also removed
# the custom DEFAULT_ACTIONS_URL knob, so the only fix this repo owns is to
# stop depending on the public resolver at all.
#
# KEEP IN SYNC with the same step in the other job. This logic deliberately
# does NOT live in scripts/*.ps1 (unlike the Python / MSVC probes): the file is
# not checked out yet at the moment it would be needed -- chicken and egg.
#
# $ErrorActionPreference is set to Continue EXPLICITLY. The runner executes a
# run: block by dot-sourcing it into a session, so an inherited Stop turns any
# raw native stderr line into a TERMINATING NativeCommandError. Measured
# 2026-10-27, run 44 / task-72: a benign "error: No such remote 'origin'" from
# a probe killed the whole job 3 seconds in, before any gate could run.
$ErrorActionPreference = 'Continue'
function Invoke-DtuGit {
# Single funnel for git. Two reasons it must be a funnel, not bare calls:
# 1) native stderr has to be flattened into plain strings (see above), and
# 2) $LASTEXITCODE has to stay readable by the caller afterwards.
# Returns Code + lines and lets the caller decide what to print.
param([string[]]$G)
$lines = & git @G 2>&1 | ForEach-Object { [string]$_ }
return [pscustomobject]@{ Code = $LASTEXITCODE; Lines = @($lines) }
}
if (-not $env:DTU_REPO_SHA) { Write-Host '::error::DTU_REPO_SHA is empty'; exit 1 }
if (-not $env:DTU_REPO_URL) { Write-Host '::error::DTU_REPO_URL is empty'; exit 1 }
# Never let a fetch wait for a human: the runner has no console, and a
# credential prompt would burn the whole job timeout while looking like a hung
# CI. Anonymous fetch works here (the repository is public; measured HTTP 200
# on /info/refs?service=git-upload-pack), so CI must not depend on a credential
# store it cannot answer.
$env:GIT_TERMINAL_PROMPT = '0'
$ws = (Get-Location).Path
Write-Host "workspace: $ws"
if (-not (Test-Path -LiteralPath (Join-Path $ws '.git'))) {
$r = Invoke-DtuGit -G @('init', '-q', '.')
if ($r.Code -ne 0) { Write-Host '::error::git init failed'; $r.Lines | ForEach-Object { Write-Host " $_" }; exit 1 }
}
# "git remote get-url" writes to stderr when the remote is absent -- that is
# the exact line that killed run 44. Ask the config instead: an absent key
# prints nothing and only sets a non-zero code, so nothing escapes as an error.
$probe = Invoke-DtuGit -G @('config', '--get', 'remote.origin.url')
$url = ($probe.Lines -join '').Trim()
if ($probe.Code -eq 0 -and $url) {
$r = Invoke-DtuGit -G @('remote', 'set-url', 'origin', $env:DTU_REPO_URL)
} else {
$r = Invoke-DtuGit -G @('remote', 'add', 'origin', $env:DTU_REPO_URL)
}
if ($r.Code -ne 0) { Write-Host '::error::configuring remote origin failed'; $r.Lines | ForEach-Object { Write-Host " $_" }; exit 1 }
# Full refspec, NOT --depth 1: the commit-message gate resolves base..head
# ranges and several gates diff against history, so a shallow clone turns them
# into "unknown revision" failures. Tags are skipped on purpose: this repository
# has zero tags and no script uses "git describe --tags" (measured 2026-10-27).
$r = Invoke-DtuGit -G @('config', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*')
if ($r.Code -ne 0) { Write-Host '::error::setting remote.origin.fetch failed'; exit 1 }
# The token, when the instance provides one, travels only as a per-invocation
# -c override: it never lands in .git/config and is never echoed in logs.
$auth = @('-c', 'credential.helper=', '-c', 'credential.interactive=false')
if ($env:DTU_REPO_TOKEN) {
# Same scheme actions/checkout uses against Gitea git-http: Basic with a
# placeholder user and the token as the password. (Bearer is the GitHub API
# style, not the git-http style.) Honest caveat: this branch CANNOT be
# verified here -- the repository is public, so an anonymous fetch succeeds
# and any token we fabricate locally only proves that a bad one is
# rejected. If the repository ever goes private this is the first line to
# check; the failure mode is a loud rc=1, never a silent fallback.
$pair = 'x-access-token:' + $env:DTU_REPO_TOKEN
$b64 = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair))
$auth += @('-c', ('http.extraheader=AUTHORIZATION: Basic ' + $b64))
}
$f = Invoke-DtuGit -G ($auth + @('fetch', '--prune', '--no-tags', 'origin'))
if ($f.Code -ne 0) {
$f.Lines | ForEach-Object { Write-Host " $_" }
Write-Host ('::error::git fetch origin failed (exit ' + $f.Code + ')')
exit 1
}
# Some event refs (PR merge commits, refs outside refs/heads) are not covered
# by the branch refspec -- fall back to an explicit ref fetch, then INSIST the
# wanted SHA is really present rather than silently building the wrong tree.
$want = $env:DTU_REPO_SHA + '^{commit}'
$chk = Invoke-DtuGit -G @('cat-file', '-e', $want)
if ($chk.Code -ne 0) {
Write-Host 'sha not covered by the branch refspec -- fetching the event ref explicitly'
$f2 = Invoke-DtuGit -G ($auth + @('fetch', '--prune', '--no-tags', 'origin', $env:DTU_REPO_REF))
if ($f2.Code -ne 0) { $f2.Lines | ForEach-Object { Write-Host " $_" } }
$chk = Invoke-DtuGit -G @('cat-file', '-e', $want)
}
if ($chk.Code -ne 0) {
Write-Host ('::error::' + $env:DTU_REPO_SHA + ' is not present in the fetched history')
Write-Host ' (refusing to run the gates against a tree we did not verify)'
exit 1
}
$c = Invoke-DtuGit -G @('checkout', '-q', '--detach', $env:DTU_REPO_SHA)
if ($c.Code -ne 0) { $c.Lines | ForEach-Object { Write-Host " $_" }; Write-Host '::error::detached checkout failed'; exit 1 }
$rr = Invoke-DtuGit -G @('reset', '--hard', '-q', $env:DTU_REPO_SHA)
if ($rr.Code -ne 0) { Write-Host '::error::reset --hard failed'; exit 1 }
# -ffd, deliberately not -ffdx: -x would delete IGNORED files, and the runner
# reuses those across runs (pip / pre-commit caches live in the workspace).
$cl = Invoke-DtuGit -G @('clean', '-ffd', '-q')
if ($cl.Code -ne 0) { $cl.Lines | ForEach-Object { Write-Host " $_" }; Write-Host '::error::git clean failed'; exit 1 }
$head = (Invoke-DtuGit -G @('rev-parse', '--short', 'HEAD')).Lines -join ''
$ncommits = (Invoke-DtuGit -G @('rev-list', '--count', 'HEAD')).Lines -join ''
$dirty = @(Invoke-DtuGit -G @('status', '--porcelain')).Lines.Count
Write-Host ('checkout ok: ' + $head + ' | commits=' + $ncommits + ' | dirty_files=' + $dirty)
- 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: |
# Result visibility: 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