Files
STM32F4-Base/.gitea/workflows/ci.yml
T
wandering1 2d18193d38 fix(ci): 去掉 actions/checkout 外网依赖,MSVC 探测改 vswhere 并钉住工具集世代
两个 job 的第 1 步原先是 `uses: actions/checkout@v4`。`uses:` 的解析发生在
Prepare all required actions 阶段——先于任何步骤、无条件联网(默认 github.com),
且每个 job 各自解析进自己的 .cache/act/<hash>、跨 job 不复用。这条链在本机实测
不稳:run 36(10-21)/ 41(10-25)/ 42(10-26)三次都是 ~45 s 后整 job 死亡、
零钩子输出,而同一分钟手工 curl 同一地址返回 200——手工可达 ≠ CI 可用(解析器
跑在 SYSTEM 下)。Gitea 1.25.4 又去掉了自定义 DEFAULT_ACTIONS_URL,服务端没有
可拧的键,唯一归本仓库管的修法就是不再依赖它:两个 job 第 1 步换成原生 git 全量检出。

关键选择(依据与实测表见 docs/CI/远程CI完整指南.md 同文 §5.3,此处只记结论):
- 完整 refspec + 全量,不是 --depth 1:提交信息门禁要解 base..head 的 range;
  且目标 sha 不在历史里就 ::error:: 硬失败,绝不默默构建一棵错的树;
- clean -ffd 而非 -ffdx:-x 会删被忽略文件,而 runner 工作区跨运行复用它们;
- GIT_TERMINAL_PROMPT=0 + 空 credential.helper:否则认证挑战会等人类输入,
  把 30 分钟 job 超时白烧掉,表现得像 CI 挂了;
- 令牌只走 per-invocation -c http.extraheader(Basic + x-access-token,与
  checkout@v4 同拼法),不落 .git/config、不回显;仓库公开时匿名即可;
- 不抓 tag:本仓库 tag 数 0、无脚本用 describe --tags(实测)。

另修 scripts/ci_runner_bootstrap.ps1 的 Find-MsvcCl。旧版只扫 %ProgramFiles%
派生根且取首个命中:本机 C 盘 VS2019 已于今日 01:00 卸载(C 盘 x64 cl.exe 实测
0 个),旧版因此是「找不到 → 只 Warn → 不写」,机器级变量一直指着那条失效路径,
而 harness 对「显式设置但无效」是硬失败、不做自动回退。现在改为 vswhere 取实例
(能看见非默认盘)+ 工具集版本降序 + -MsvcToolset 前缀钉住世代(默认 14.51.,
与覆盖率基线同版本;钉不上连 WARN 再回落最新,绝不静默)+ -MsvcLatest;
-SetEnv 与 -Status 同步纳入 DTU_MS_COVERAGE。

验证(内层一律 powershell.exe 5.1,忠实复现 runner 执行方式):临时工作区六项
A/B/C/D/E/F——E 是执行**从 ci.yml 里抽出的正文**,测的就是文件那份字节。
A rc=0、commits=665、refspec 完整、分离 HEAD;B 复用工作区后 dirty_files=0、
被忽略文件保留、17ece86..HEAD 解出 18 个提交;C rc=1 且 HEAD 不动;D 坏令牌
fatal: unable to get password 秒失败;令牌改造后再跑 ANON/BOGUS/ANON2=0/1/0。
ci_local 全量:L0.5 = 40 runner、3188/3188 断言、fail=0(MSVC 14.51);
test-coverage 与 ui-golden 均 Passed。.ps1 非 ASCII 字节 0、语法错误 0、无 BOM、
LF;ci.yml 无 BOM 无 CR、13 步、0 个 run 块含非 ASCII、uses: 计数 0。
2026-10-27 08:00:14 +08:00

752 lines
45 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 `uses: actions/checkout`: 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 (each job
# re-resolves into its own .cache/act/<hash>). 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.
#
# NOTE on $ErrorActionPreference: left at Continue on purpose. Under Windows
# PowerShell 5.1 a native command that writes to stderr while EAP is Stop
# raises NativeCommandError -- git legitimately writes progress to stderr.
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 }
$ws = (Get-Location).Path
Write-Host "workspace: $ws"
if (-not (Test-Path -LiteralPath (Join-Path $ws '.git'))) {
git init -q .
if ($LASTEXITCODE -ne 0) { Write-Host '::error::git init failed'; exit 1 }
}
$rem = git remote get-url origin 2>$null
if ($LASTEXITCODE -eq 0 -and $rem) {
git remote set-url origin $env:DTU_REPO_URL
} else {
git remote add origin $env:DTU_REPO_URL
}
if ($LASTEXITCODE -ne 0) { Write-Host '::error::configuring remote origin failed'; 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).
git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'
# Never let a fetch wait for a human: the runner has no console and a
# credential prompt would burn the whole 30 m job timeout while looking like
# a hung CI. Terminal prompts off + no credential helper means an auth
# problem fails immediately with a message instead of blocking. Anonymous
# fetch works here (the repository is public; measured HTTP 200 on
# /info/refs?service=git-upload-pack), so CI must not start depending on a
# credential store it cannot answer.
$env:GIT_TERMINAL_PROMPT = '0'
# 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.
# Anonymous fetch works too (the repository is public), so CI does not start
# depending on token availability.
$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 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))
}
# PS 5.1 wraps each native stderr line into an ErrorRecord, turning two real
# log lines into eight of CategoryInfo/FullyQualifiedErrorId noise. Flattening
# with "$_" keeps the log readable; $LASTEXITCODE is unaffected.
$fetchArgs = $auth + @('fetch', '--prune', '--no-tags', 'origin')
& git @fetchArgs 2>&1 | ForEach-Object { "$_" }
if ($LASTEXITCODE -ne 0) { Write-Host "::error::git fetch origin failed (exit $LASTEXITCODE)"; exit 1 }
# Some 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.
git rev-parse --verify -q ($env:DTU_REPO_SHA + '^{commit}') 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host 'sha not covered by the branch refspec -- fetching the event ref explicitly'
$refArgs = $auth + @('fetch', '--prune', '--no-tags', 'origin', $env:DTU_REPO_REF)
& git @refArgs 2>&1 | ForEach-Object { "$_" }
}
git rev-parse --verify -q ($env:DTU_REPO_SHA + '^{commit}') 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Host ('::error::' + $env:DTU_REPO_SHA + ' is not present in the fetched history'); exit 1 }
& git checkout -q --detach $env:DTU_REPO_SHA 2>&1 | ForEach-Object { "$_" }
if ($LASTEXITCODE -ne 0) { Write-Host '::error::detached checkout failed'; exit 1 }
git reset --hard -q $env:DTU_REPO_SHA
if ($LASTEXITCODE -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).
git clean -ffd -q
Write-Host ('checkout ok: ' + (git rev-parse --short HEAD) + ' | commits=' + (git rev-list --count HEAD) + ' | dirty_files=' + ((git status --porcelain | Measure-Object).Count))
- 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 `uses: actions/checkout`: 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 (each job
# re-resolves into its own .cache/act/<hash>). 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.
#
# NOTE on $ErrorActionPreference: left at Continue on purpose. Under Windows
# PowerShell 5.1 a native command that writes to stderr while EAP is Stop
# raises NativeCommandError -- git legitimately writes progress to stderr.
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 }
$ws = (Get-Location).Path
Write-Host "workspace: $ws"
if (-not (Test-Path -LiteralPath (Join-Path $ws '.git'))) {
git init -q .
if ($LASTEXITCODE -ne 0) { Write-Host '::error::git init failed'; exit 1 }
}
$rem = git remote get-url origin 2>$null
if ($LASTEXITCODE -eq 0 -and $rem) {
git remote set-url origin $env:DTU_REPO_URL
} else {
git remote add origin $env:DTU_REPO_URL
}
if ($LASTEXITCODE -ne 0) { Write-Host '::error::configuring remote origin failed'; 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).
git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'
# Never let a fetch wait for a human: the runner has no console and a
# credential prompt would burn the whole 30 m job timeout while looking like
# a hung CI. Terminal prompts off + no credential helper means an auth
# problem fails immediately with a message instead of blocking. Anonymous
# fetch works here (the repository is public; measured HTTP 200 on
# /info/refs?service=git-upload-pack), so CI must not start depending on a
# credential store it cannot answer.
$env:GIT_TERMINAL_PROMPT = '0'
# 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.
# Anonymous fetch works too (the repository is public), so CI does not start
# depending on token availability.
$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 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))
}
# PS 5.1 wraps each native stderr line into an ErrorRecord, turning two real
# log lines into eight of CategoryInfo/FullyQualifiedErrorId noise. Flattening
# with "$_" keeps the log readable; $LASTEXITCODE is unaffected.
$fetchArgs = $auth + @('fetch', '--prune', '--no-tags', 'origin')
& git @fetchArgs 2>&1 | ForEach-Object { "$_" }
if ($LASTEXITCODE -ne 0) { Write-Host "::error::git fetch origin failed (exit $LASTEXITCODE)"; exit 1 }
# Some 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.
git rev-parse --verify -q ($env:DTU_REPO_SHA + '^{commit}') 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host 'sha not covered by the branch refspec -- fetching the event ref explicitly'
$refArgs = $auth + @('fetch', '--prune', '--no-tags', 'origin', $env:DTU_REPO_REF)
& git @refArgs 2>&1 | ForEach-Object { "$_" }
}
git rev-parse --verify -q ($env:DTU_REPO_SHA + '^{commit}') 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { Write-Host ('::error::' + $env:DTU_REPO_SHA + ' is not present in the fetched history'); exit 1 }
& git checkout -q --detach $env:DTU_REPO_SHA 2>&1 | ForEach-Object { "$_" }
if ($LASTEXITCODE -ne 0) { Write-Host '::error::detached checkout failed'; exit 1 }
git reset --hard -q $env:DTU_REPO_SHA
if ($LASTEXITCODE -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).
git clean -ffd -q
Write-Host ('checkout ok: ' + (git rev-parse --short HEAD) + ' | commits=' + (git rev-list --count HEAD) + ' | dirty_files=' + ((git status --porcelain | Measure-Object).Count))
- 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