merge: master 的远程 CI 修复带入 v2-on-ui(门禁数字改为分支中立)

把 master 上本轮 10 个 CI 相关提交合入 v2-on-ui,使该分支同样具备可用的
runner 保活与本地门禁自查能力。合并只涉及文档与脚本,零 C 代码改动。

冲突两处,均按"保留 v2-on-ui 新版结构 + 搬入语义"解决:
- AGENTS.md:丢弃 master 侧已被瘦身版取代的旧门禁 bullet,保留其分层边界
  行;把"CI 资料集中在 docs/CI/README.md、只跑本机一台 runner"这条指针
  并入新版 CI bullet 与文末索引表。
- docs/README.md:两侧都是变更记录历史行,按日期先后并列保留。

数字口径改为分支中立:本分支 26 个钩子 / 10 个主机单测套件,master 是
18 / 3,写死任一数字都会让同一篇文档在另一分支上失真。指南 §6 验收第
8 行、§7 排障表、§10 验收标准改为讲规则(报告数 = 定义数 − 1;runner 数
= test/host 下 run_unit_test.py 个数)+ 分分支实测快照。

验证:@build 0 错误 0 警告;ci_local.py 全绿(L0 25 钩子 + L0.5 10 runner
/ 647 断言,fail=0)。首轮 mem-numbers 失败系 master 构建遗留的 .map 所
致,本分支重新构建后即一致。
This commit is contained in:
2026-10-12 18:28:02 +08:00
16 changed files with 2641 additions and 1434 deletions
+128 -106
View File
@@ -21,7 +21,7 @@
#
# 产物存档(S3,2026-10-02):两个 job 把关键输出 `Tee-Object` 到 `ci-artifacts/*.log`,
# 并写 $GITHUB_STEP_SUMMARY(Gitea UI 直接可见)+ `::notice::` 汇总行。
# **刻意不用 `actions/upload-artifact`**——内网可能拉不到外层 action(见 CI配置指南 §4.3);
# **刻意不用 `actions/upload-artifact`**——内网可能拉不到外层 action(见 docs/CI/远程CI完整指南.md §5.3);
# 零依赖方案下日志留在 runner 工作区,UI 通过 step-summary 即可排障。
# ⚠️ `ci-artifacts/` 已 gitignore(运行时产物)。
#
@@ -43,6 +43,35 @@
# 那些由 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` 定义 18 个钩子,CI 只报 17 个(`passed=16 skipped=1`)——
# 缺的正是 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`),且装有:
@@ -97,98 +126,73 @@ jobs:
# 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.
$repoRoot = (Get-Location).Path
# --- pick a Python that actually has pre-commit -------------------
# The runner runs as SYSTEM, whose PATH is NOT the dev user's PATH.
# Probe order per the DTU_* env var plan (kept in sync with
# scripts/ci_local.py):
# DTU_PRECOMMIT_PYTHON (explicit; invalid value = hard error)
# -> PATH python -> repo .venv
# 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.
#
# CAUTION 1: a failing native command writes to stderr; with
# $ErrorActionPreference='Stop' that would abort the whole script
# (NativeCommandError) before the next candidate is tried. So relax
# the preference around each probe, and restore it afterwards.
#
# CAUTION 2: pre-commit may be installed in the USER site of some
# account (e.g. %APPDATA%\Roaming\Python\...), which SYSTEM cannot
# see at all -- no python.exe path can help then. Probe with `-s`
# (no user-site) so we only accept an interpreter that works for
# SYSTEM, and say so clearly when none is found.
$py = $null
$saved = $ErrorActionPreference
$envPy = $env:DTU_PRECOMMIT_PYTHON
if ($envPy) {
if (-not (Test-Path $envPy)) {
$ErrorActionPreference = 'Stop'
Write-Host "::error::DTU_PRECOMMIT_PYTHON is set but does not exist: $envPy"
Write-Host "::error::fix the variable (full path to a python.exe with pre-commit), or unset it to use auto-discovery"
exit 1
}
$ErrorActionPreference = 'Continue'
& $envPy -s -m pre_commit --version 2>&1 | Out-Null
$rc = $LASTEXITCODE
$ErrorActionPreference = $saved
if ($rc -ne 0) {
$ErrorActionPreference = 'Stop'
Write-Host "::error::DTU_PRECOMMIT_PYTHON does not have a SYSTEM-visible pre-commit: $envPy"
exit 1
}
$py = $envPy
} else {
$candidates = @(
(Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source),
(Join-Path $repoRoot '.venv\Scripts\python.exe')
)
foreach ($c in $candidates) {
if (-not $c) { continue }
if (-not (Test-Path $c)) { continue }
$ErrorActionPreference = 'Continue'
& $c -s -m pre_commit --version 2>&1 | Out-Null
$rc = $LASTEXITCODE
$ErrorActionPreference = $saved
if ($rc -eq 0) { $py = $c; break }
}
}
$ErrorActionPreference = 'Stop'
# 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::no Python with a SYSTEM-visible pre-commit (DTU_PRECOMMIT_PYTHON not set; tried PATH python and repo .venv)"
Write-Host "::error::pre-commit is probably installed only in a USER site (invisible to SYSTEM)."
Write-Host "::error::fix: set a MACHINE-level DTU_PRECOMMIT_PYTHON for the runner service and restart it, or install pre-commit globally for the SYSTEM account (see the Gitea runner deployment guide in docs/)"
exit 1
Write-Host "::error::ci_find_python.ps1 succeeded but returned no path"
exit 2
}
Write-Host "using python: $py"
& $py --version
& $py -s -m pre_commit --version
"PYTHON=$py" | Out-File -FilePath $env:GITHUB_ENV -Append
$py = "$py".Trim()
# --- cppcheck -----------------------------------------------------
# Order per the DTU_* plan: DTU_CPPCHECK (explicit; invalid = hard
# error, no fallback) -> PATH (Get-Command cppcheck). No fixed
# install-dir guess: a USER-scope PATH entry is invisible to SYSTEM,
# so on the runner set a MACHINE-level DTU_CPPCHECK if cppcheck is
# not on SYSTEM's PATH.
# 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
}
$dir = Split-Path -Parent $envCpp
$env:Path = "$env:Path;$dir"
$dir | Out-File -FilePath $env:GITHUB_PATH -Append
Write-Host "using cppcheck from DTU_CPPCHECK: $envCpp"
& $envCpp --version
$found = $envCpp
Write-Host "using cppcheck from DTU_CPPCHECK: $found"
} else {
$cpp = Get-Command cppcheck -ErrorAction SilentlyContinue
if (-not $cpp) {
Write-Host "::error::runner missing cppcheck (not on SYSTEM PATH) -- set a machine-level DTU_CPPCHECK, or add its directory to the machine PATH, then re-run"
$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
}
Write-Host "found cppcheck: $($cpp.Source)"
cppcheck --version
}
$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"
@@ -196,6 +200,34 @@ jobs:
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 (17 hooks defined, 16 reported).
# 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
@@ -213,14 +245,14 @@ jobs:
$map = Get-ChildItem -Path 'MDK-ARM' -Recurse -Filter '*.map' -ErrorAction SilentlyContinue
if ($map) {
Write-Host "found .map: $($map[0].FullName) -- mem-numbers runs normally"
"value=" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"sizegate=on" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"value=" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
"sizegate=on" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
"> Volume gate (mem-numbers): 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, so the Flash/RAM size gate does NOT run in CI. Size changes are NOT caught here; verify locally with 'python scripts/check_mem_numbers.py --scan' after a Keil build."
"value=mem-numbers" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"sizegate=off" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"value=mem-numbers" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
"sizegate=off" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
"> :warning: Volume gate (mem-numbers): **NOT RUN** -- no `.map` (no Keil build)." |
Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
}
@@ -339,10 +371,10 @@ jobs:
}
}
if ($found) {
"value=yes" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"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
"value=no" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
}
- name: 运行主机侧单测
@@ -350,28 +382,18 @@ jobs:
if: steps.msvc.outputs.value == 'yes'
shell: powershell
run: |
# Resolve a working Python first (SYSTEM's PATH python may be unusable).
# Order per the DTU_* plan: DTU_PRECOMMIT_PYTHON (explicit; invalid =
# hard error) -> PATH python -> repo .venv.
$repoRoot = (Get-Location).Path
$py = $null
$envPy = $env:DTU_PRECOMMIT_PYTHON
if ($envPy) {
if (-not (Test-Path $envPy)) {
Write-Host "::error::DTU_PRECOMMIT_PYTHON is set but does not exist: $envPy"
exit 1
}
$py = $envPy
}
# 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) {
foreach ($c in @(
(Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source),
(Join-Path $repoRoot '.venv\Scripts\python.exe')
)) {
if ($c -and (Test-Path $c)) { $py = $c; break }
Write-Host "::error::ci_find_python.ps1 succeeded but returned no path"
exit 2
}
}
if (-not $py) { Write-Host "::error::no python found (set DTU_PRECOMMIT_PYTHON or fix the runner PATH)"; exit 1 }
$py = "$py".Trim()
Write-Host "using python: $py"
New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null
@@ -423,8 +445,8 @@ jobs:
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
"agg=$agg" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"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 日志(供下载/排障)
+3 -2
View File
@@ -92,7 +92,7 @@
- ⚠ **pre-commit 4.x 已移除 `always: true`**(实测对 `local` 钩子只告警 `Unexpected key(s)` 并跳过)→ 想"总是跑"的钩子**必须让 `files:` 覆盖到所有会触发它的文件**,没有兜底开关
- **改写型钩子(`trailing-whitespace` + `end-of-file-fixer`)豁免 CubeMX/Keil 生成文件**(配置里 YAML 锚点 `&cubemx-generated`);⚠ **只读检查不受影响**:`cppcheck` / 乱码 / `cubemx-survival` / `task-table` / `mem-numbers` 仍覆盖 `Src/`、`Inc/`
- 提交信息:标题 ≤100 字符、**正文每行 ≤80**(`.gitlint` + gitlint 默认)→ 长说明一律用**短标题 + `git commit -F <UTF-8 无 BOM 文件>` 多行正文**(中文长标题极易超限,且 `-m` 会被终端代码页转码破坏)
- **CI**(`.gitea/workflows/ci.yml`)只跑 L0 静态门禁 + L0.5 主机侧单测,**刻意不跑 `@build`**(需 Keil 授权)、不含 L1/L2/L3(需真机);本地等价自查 `& '.venv\Scripts\python.exe' scripts/ci_local.py`——**runner 未就绪时这是 L0 的唯一保障**
- **CI**(`.gitea/workflows/ci.yml`)只跑 L0 静态门禁 + L0.5 主机侧单测,**刻意不跑 `@build`**(需 Keil 授权)、不含 L1/L2/L3(需真机)。**远程 CI 的全部资料(服务端开关 / runner 部署与保活 / workflow / 排障 / 实测坑)集中在 [`docs/CI/README.md`](docs/CI/README.md),不放本节**——它**只跑在本机一台 runner `win-stm32-ci` 上**,其他机器与项目都不涉及。日常改代码的等价自查是 `& '.venv\Scripts\python.exe' scripts/ci_local.py`(L0 + L0b + L0.5)——**runner 不在线也不影响本地门禁,不能因"已配 CI"放宽本地要求**
## 平台约束
@@ -176,4 +176,5 @@
| 串口助手 | **`serial-assistant` skill**(命令/判据/陷阱,用到自动加载)、`docs/测试说明/串口助手使用指南.md`、`串口助手设计说明.md` |
| UI 架构 / 模拟器 / UI 自动化回归 | **`lvgl-simulator` skill**(构建/自检/报错速查,用到自动加载)、`docs/UI设计/UI_设计架构.md`、`LVGL模拟器部署与使用说明.md`、`UI_自动化测试流程.md`;UI 改动回归走 **`ui-verify` skill** |
| 待办与审计发现登记簿 | `docs/待处理内容.md` |
| 换机 / 工具路径环境变量 / CI | `docs/环境与部署/` |
| 换机 / 工具路径环境变量 | `docs/环境与部署/` |
| 远程 CI(Gitea Actions,**只跑本机一台 runner**) | [`docs/CI/README.md`](docs/CI/README.md) |
+88
View File
@@ -0,0 +1,88 @@
# 远程 CI(Gitea Actions)—— 本目录是唯一入口
> **适用范围(先读这句,可以省下很多时间)**
> 本仓库的远程 CI **只跑在本机这一台 runner 上**:runner 名 `win-stm32-ci`,装在
> `D:\Docker\act_runner`,连的是本机原生 Gitea `http://127.0.0.1:3001`。
> **其他机器、其他项目都不涉及,也不需要配这套东西。**
>
> 日常改代码**不需要读本目录**:提交前的等价自查是本地门禁,runner 在不在线都照样跑——
>
> ```powershell
> & '.venv\Scripts\python.exe' scripts/ci_local.py # L0 静态 + L0b 提交信息 + L0.5 主机单测
> ```
>
> 只有在这三种情况下才需要进来:**① 换机/重装后要重新部署 runner;② CI 不出任务或莫名
> 取消;③ 改 `.gitea/workflows/ci.yml` 本身。**
## 索引
全部内容是**一篇**文档:[`远程CI完整指南.md`](远程CI完整指南.md)(2026-10-12 由原「环境与部署」
目录下的两篇指南 `CI配置指南.md` 与 `Gitea_Runner部署指南.md` 合并而来,**两篇均已删除**,
去重后为单一来源)。
| 想干什么 | 去看 |
|---|---|
| 先搞清 CI 需要哪三样东西就位、本机拓扑、部署前置清单 | §0 / §0.1 / §0.2 |
| **CI 不守哪些门禁**(体积门禁盲区 S14、gitlint 空转) | §0.0 + §7 排障表"钩子条数对不上"行 |
| 服务端开启 Actions、`DEFAULT_ACTIONS_URL` 到底管什么 | §1(含 §1.2.1,**这条最容易搞反**) |
| 下载 / 注册 runner(`--instance` 用回环、labels 必须精确匹配 `runs-on`) | §2.2 |
| `config.yaml` 各字段语义(capacity / timeout / labels / 日志目录…) | §2.3 |
| **常驻:为什么必须看门狗、不能只起 daemon、也不能做成 Windows 服务** | §2.4 + §3.0 |
| runner 装什么软件、为什么变量必须对 **SYSTEM** 可见、机器级 `DTU_*` | §4(§4.1.1~§4.1.3 是三个真坑) |
| workflow 位置 / 触发 / 内网拉不到 `actions/checkout` 的三种处置 | §5(§5.3 零依赖写法) |
| 两个 job 各自跑哪些步骤、两条 Python 探测链要同序同步 | §5.5 |
| 端到端验证清单、无网页登录时直查 MySQL 核验 | §6 / §6.1 |
| 出问题了按症状查表(含 `0x41301`、1053、72h 被杀、`.ps1` 无 BOM 乱码…) | §7 |
| 日常维护(改钩子集 / 改 workflow 后要不要动 runner) | §8 |
| 一键自检脚本、最小操作清单 TL;DR | §9 / §10 |
## 常用命令速查
```powershell
# 本地等价门禁(不依赖 runner,日常用这个)
& '.venv\Scripts\python.exe' scripts/ci_local.py
# runner 体检(四项:计划任务 / 进程 / Gitea 可达 / 机器级变量)—— 须管理员
& '.\scripts\ci_runner_bootstrap.ps1' -Status
# 干跑:只打印将要写入的启动器与任务对象,不落盘不注册(免管理员)
& '.\scripts\ci_runner_bootstrap.ps1' -DryRun
# 部署 / 修复:写机器级 DTU_* + 生成看门狗启动器 + 注册开机计划任务 + 立即启动(须管理员)
& '.\scripts\ci_runner_bootstrap.ps1' -SetEnv -StartNow
```
> ⚠️ 上面三条 `ci_runner_bootstrap.ps1` 是**本机运维**动作,会改系统计划任务与机器级环境
> 变量;返回码 `0x41301` 是"任务正在运行"、`next run` 为空是开机触发器的正常表现,
> 都不是故障(详见指南 §3.4)。
> ✅ **机器侧遗留已清理(2026-10-12)**:旧方案的 `deploy_runner_task.ps1` 与 `run_runner.cmd`
> 已从 `D:\Docker\act_runner\` **删除**(它们跑单次 daemon,首次 Declare 失败就永久停摆,
> 机理见指南 §3.2;`check_env.ps1` 里指向它们的提示也已改指 bootstrap)。
> 现行部署只有 `ci_runner_bootstrap.ps1` 这一条路。
> 🔴 **重跑部署前先知道这条**(2026-10-12 实测):`Unregister-ScheduledTask` **不会**带走旧实例
> 启动的进程。旧看门狗会作为孤儿继续养着它的 daemon,而那个 daemon 占着 `.runner.lock`,
> 于是新看门狗每 10 秒失败一次(`runner file is already locked by another process`)——
> **而 runner 在网页上照样显示 online**,CI 看着正常,任务其实谁也没管。
> 现在 `-StartNow` 会先清掉这些残留进程再启动(约离线几秒),`-Status` 也会报出重复实例。
> **2026-10-12 已实测收敛**:清掉 3 个残留后 `daemon up: pid … (single instance)`,
> 监管链 `svchost(任务) → 看门狗 → daemon` 恢复,runner 回 online。
## 相关文档
- 测试分层与门禁决策(L0~L3 各层由谁把关):[`../测试说明/测试开发标准流程.md`](../测试说明/测试开发标准流程.md)
- workflow 本体:[`../../.gitea/workflows/ci.yml`](../../.gitea/workflows/ci.yml)
- 本地等价脚本:[`../../scripts/ci_local.py`](../../scripts/ci_local.py)
- runner 机器的工具链环境要求:[`../环境与部署/项目部署环境要求.md`](../环境与部署/项目部署环境要求.md)
- `DTU_*` 工具路径变量机制:[`../环境与部署/本地工具路径环境变量方案.md`](../环境与部署/本地工具路径环境变量方案.md)
## 变更记录
| 日期 | 变更 |
|---|---|
| 2026-10-12 | 🔀 本轮 CI 修复随 `master` **合入 `v2-on-ui`**(该分支另有 21 个功能提交;冲突仅 `AGENTS.md` 与 `docs/README.md` 两处,解法是"保留其新版结构 + 把 CI 指针搬进对应位置")。因该分支门禁更多(**26 钩子 / 10 单测套件** vs master 的 18 / 3),指南里写死的数字已改为**规则 + 分分支快照**,见指南 §6 / §7 / §10 |
| 2026-10-12 | ✅ 双看门狗缺陷**实测收敛**(管理员 `-StartNow` 清掉 3 个残留 → 单实例、链路恢复、runner online)。另修两处:daemon 日志走 stderr 被 PS 5.1 `2>&1` 包成 ErrorRecord(2 行真日志写成 8 行,每 4 行 3 行噪声)→ 启动器改 `ForEach-Object { "$_" }`;`-Status` 诚实化(非管理员读不到 SYSTEM 进程命令行 → 明说需提权而非谎报"无看门狗";重启循环判据改看进程存活时长,不再靠不轮转日志的尾部字符串报假警)。详见指南 §7.3 |
| 2026-10-12 | 🔴 **修复"双看门狗抢 `.runner.lock`"缺陷**:`Unregister-ScheduledTask` 不停旧实例进程 → 孤儿看门狗养着旧 daemon 占锁,新看门狗每 10 秒失败一次,而 runner 网页照样 online、`-Status` 也说"在跑"。现 `-StartNow` 先清残留进程再启动、`-Status` 报重复实例与锁冲突。同日清理机器侧:`config.yaml` 头部错误指引("改用 sc.exe / New-Service")改为看门狗口径,删除已废弃的 `deploy_runner_task.ps1` 与 `run_runner.cmd`,`check_env.ps1` 提示同步改指 bootstrap |
| 2026-10-12 | **单一口径清理**:合并稿初版对 4 处分歧"两说并存",现统一为本机实测现状——注册口径(`gitea-runner v4.0.1` / `D:\Docker\act_runner` / `127.0.0.1:3001`,旧 `act_runner v0.2.11` + `C:\act_runner` 段删去可复制命令只留警示)、`config.yaml`(`fetch_timeout: 1m0s`、`timeout: 30m0s`)、常驻方式(只认看门狗,`deploy_runner_task.ps1` 标废弃)、验收数字(L0.5 `288/288`,历史数字标注为快照)。并补"机器侧遗留脚本勿误用"警示 |
| 2026-10-12 | **新建本目录**:远程 CI 内容原先散在 `docs/环境与部署/`(两篇互相重叠的指南)、`docs/测试说明/测试开发标准流程.md` §9、`AGENTS.md` 提交门禁节共 4 处,约 2000 行;现合并为单一指南并把 `AGENTS.md` 压成一行指针。动因:远程 CI 只涉及本机一台 runner,与日常开发规范混在一起每次会话都被读,造成困惑 |
File diff suppressed because it is too large Load Diff
+3
View File
@@ -24,6 +24,7 @@ docs/
├── 内存管理/ RAM 预算、分配策略与优化调研
├── 问题记录/ 模块陷阱台账、专项审计与项目评审报告
├── 环境与部署/ 环境搭建与部署核对清单
├── CI/ 远程 CI(Gitea Actions)唯一入口 —— **只涉及本机一台 runner,日常开发不必读**
├── UI设计/ UI 架构、页面设计、字体与模拟器
├── 硬件平台/ 网表分析报告与语义图
├── 芯片手册/ 外部芯片数据手册转录(只读资料)
@@ -220,6 +221,7 @@ docs/
| 文档 | 内容 |
|---|---|
| [`项目部署环境要求.md`](环境与部署/项目部署环境要求.md) | 开发机软件环境、目标板资源预算、现场部署与验收步骤 |
| [`CI/README.md`](CI/README.md) | **远程 CI(Gitea Actions)唯一入口**:服务端开关 / runner 部署与保活 / workflow / 排障。**只涉及本机一台 runner `win-stm32-ci`,其他机器与项目都不涉及**;日常改代码用本地门禁 `scripts/ci_local.py`,不必读这里 |
### 9.3 台账与外部资料
@@ -268,6 +270,7 @@ docs/
| 日期 | 变更 | 说明 |
|---|---|---|
| 2026-10-12 | 远程 CI 文档独立成目录 | 新建 `docs/CI/`:原散在 4 处的远程 CI 内容(`环境与部署/CI配置指南.md` 477 行 + `环境与部署/Gitea_Runner部署指南.md` 917 行 + `测试开发标准流程.md` §9 + `AGENTS.md` 提交门禁节 4 条,共约 2000 行)**合并为 `CI/远程CI完整指南.md` 一篇**,`CI/README.md` 作唯一入口;`AGENTS.md` 压成一行指针、测试流程 §9 只留覆盖边界与本地等价。动因:远程 CI **只跑在本机一台 runner**,与日常开发规范混在一起每次会话都被读,造成困惑 |
| 2026-10-11 | 新增文档 | 新建 [`AGENTS编写指南.md`](AGENTS编写指南.md)(好的 AGENTS.md 怎么写:六原则/模板/反模式/维护纪律 + 本仓库瘦身重构案例与外部标杆调研);本索引 §1 目录树与 §7.1b 登记;同日 `AGENTS.md` 按该指南完成宏观重构(补项目概况/目录地图、平台约束分组) |
| 2026-10-10 | 新增子系统设计 | 新建 `docs/子系统设计/ADC采样节拍设计说明.md`(ADC 采集链路 1 kHz 硬件定时器触发的 CubeMX 配置规格与代码改造清单,B1 遗留项规划);本索引 §5.1 补登记 |
| 2026-10-10 | 新增算法专题 | 新建 `docs/算法/继电保护功能复现规划.md`(保护链路复现总规划);本索引 §1 目录树补 `算法/`、§5 拆为 5.1 功能子系统 / 5.2 算法专题并登记 `算法/` 两篇文档 |
+1 -1
View File
@@ -228,7 +228,7 @@
#### 3.3.4 A-5 残余:CI 体积门禁盲区 ⬜
**现状**:`mem-numbers` 钩子**在 CI 上被跳过**(CI 无 `.map`)→ **CI 不守体积门禁**。
S14 已在 workflow 显式提示"体积门禁未执行"(`::notice::`)并在 `CI配置指南.md` 声明,
S14 已在 workflow 显式提示"体积门禁未执行"(`::notice::`)并在 `docs/CI/远程CI完整指南.md` §0.0 声明,
但**盲区本身未消除**——看到 CI 全绿仍可能误以为体积也守住了。
**可选处置**:
+37 -26
View File
@@ -14,8 +14,7 @@
| `docs/UI设计/UI_自动化测试流程.md` | UI 模拟器回归(ui-verify)细节 |
| `docs/代码规范/pre-commit使用说明.md` | pre-commit 门禁配置与范围 |
| `docs/环境与部署/项目部署环境要求.md` | 构建 / 测试工具链环境 |
| `docs/环境与部署/CI配置指南.md` | **CI(Gitea Actions)服务端 + workflow 配置全流程与排障** |
| `docs/环境与部署/Gitea_Runner部署指南.md` | **Runner 端部署全流程**(下载/注册/常驻/排障) |
| `docs/CI/README.md` | **远程 CI(Gitea Actions)唯一入口**:服务端 / runner 部署与保活 / workflow / 排障(仅本机一台 runner,日常开发不必读) |
| `docs/测试说明/<模块>_Test_Guide.md` | 各模块测试规范(CH395F / GD5F / SD2506 / FTP) |
---
@@ -529,36 +528,48 @@ flowchart LR
> 新增任务/栈/缓冲/代码导致的体积增长**不会在 CI 被拦**。故该跳过以 `::warning::` 明示
> (不是弱化的 notice)并写入 step-summary。**兜底必须靠本地**:改代码后 `@build`(0/0)
> → `python scripts/check_mem_numbers.py --scan`。详见
> [`../环境与部署/CI配置指南.md`](../环境与部署/CI配置指南.md) §0.0。
> [`../CI/远程CI完整指南.md`](../CI/远程CI完整指南.md) §0.0。
> 🔴 **`gitlint` 属 `commit-msg` stage,`--all-files` 永远跑不到它(2026-10-11 审计)**:
> `.pre-commit-config.yaml` 定义 **18** 个钩子,而 CI 日志只报 **17** 个(`passed=16 skipped=1`)——
> 缺的正是 gitlint。即"单行中文标题 ≤100 字符"这条门禁**本地有、CI 无**,且表现为**全绿**
> 而非失败,所以长期无人发现。现由 CI 侧 **`scripts/ci_gitlint_range.ps1`**(PR 用 `base..head`、
> push 用 `event.before..sha`、跳过 merge 提交)与本地 `ci_local.py` 的 **L0b** 段分别逐条校验。
> ⚠️ 机制根因(`stages: [commit-msg]` 写在 **gitlint 上游** 的 `.pre-commit-hooks.yaml` 里,
> 项目配置中 grep 不到)与完整排障口径见
> [`../CI/远程CI完整指南.md`](../CI/远程CI完整指南.md) §7"钩子条数对不上"行。
### 9.2 两个 job
| job | `runs-on` | 步骤要点 |
| job | 覆盖层 | 一句话职责 |
|---|---|---|
| `l0-gates` | `windows` | checkout(`fetch-depth: 0`,gitlint/cubemx 需完整历史)→ 环境自检(Python / cppcheck / `python -m pre_commit`,缺则 `::error::` + exit 1)→ `pre_commit install --install-hooks` → **决定跳过哪些钩子**(探测 `.map`,写入 `GITHUB_OUTPUT`;无 `.map` 时 `::warning::` 声明体积门禁未跑)→ `pre-commit run --all-files --show-diff-on-failure`(`env: SKIP`,输出 `Tee` 到 `ci-artifacts/l0-pre-commit.log`)→ 打印 `::notice::L0: passed/failed/skipped` 汇总行 → 归档日志说明 |
| `l05-host-unit-tests` | `windows` | checkout → **探测 MSVC**(按 VS_ROOTS 找 `Hostx64\x64\cl.exe`)→ 遍历 `test/host/**/run_unit_test.py` 逐个跑(输出 `Tee` 到 `ci-artifacts/l05-<模块>.log`;解析各模块 `N/M PASSED` 汇总行 → 聚合 `::notice::L0.5: K runner(s), A/B assertions passed`;失败 `::error::` 并置退出码);**未探测到 MSVC 时打印醒目 WARNING 并降级跳过**(不硬失败,避免"没装 VS 的 runner 全红") |
| `l0-gates` | **L0** | `pre-commit run --all-files`(cppcheck / 编码规范 / 文档一致性 / 主机侧单测钩子)+ **提交信息门禁**(gitlint,见上方 🔴);无 `.map` 时自适应跳过 `mem-numbers` |
| `l05-host-unit-tests` | **L0.5** | 探测 MSVC 后遍历 `test/host/**/run_unit_test.py` 逐个跑并聚合断言数;**未装 MSVC 时降级为 WARNING 跳过**(不硬失败,但等于该层裸奔) |
**结果可见性(S3,2026-10-02)**:两个 job 均写 `$GITHUB_STEP_SUMMARY`(job 运行页的
**Summary** 区块直接可见)+ `::notice::` 汇总行;日志落 `ci-artifacts/*.log`(runner 工作区,
已 gitignore)。**刻意不用 `actions/upload-artifact`**(内网可能拉不到外层 action,见
`CI配置指南.md` §4.3/§4.4)——零依赖方案下,"不查 MySQL / 不翻磁盘即可排障"的目标由
step-summary 达成。
触发:`push`(`paths-ignore: docs/** / **/*.md / .workbuddy-ai/**` → **纯文档改动不会触发 CI**)
与 `pull_request`,另留 `workflow_dispatch` 手动触发;同分支 `cancel-in-progress: true`。
两个 job 都写 step-summary + `::notice::` 汇总行,日志落 `ci-artifacts/*.log`。
触发:`push`(`paths-ignore: docs/** / **/*.md / .workbuddy-ai/**`)与 `pull_request`,另留 `workflow_dispatch` 手动触发。
`concurrency: group: ci-${{ github.ref }}` + `cancel-in-progress: true`(同分支旧运行自动取消,省 runner)。
> 📖 **步骤级细节不在本文**(探测链顺序、`GITHUB_OUTPUT` 跳过决策、`Tee` 落盘、产物存档、
> 内网拉不到 action 的处置):见 [`../CI/远程CI完整指南.md`](../CI/远程CI完整指南.md)
> §5.5(两个 job 的步骤要点)/ §5.2(触发)/ §5.3(内网 action)/ §5.4(产物与汇总)。
### 9.3 本地等价:`scripts/ci_local.py`
CI 只在 push / PR 时跑,且需 runner 就绪。**提交前自查、或 runner 还没配好时**,用同一套判定在本地跑:
```powershell
& '.venv\Scripts\python.exe' scripts/ci_local.py # L0 + L0.5(与 CI 等价)
& '.venv\Scripts\python.exe' scripts/ci_local.py --l0-only # 只跑 L0
& '.venv\Scripts\python.exe' scripts/ci_local.py # L0 + L0b + L0.5(与 CI 等价)
& '.venv\Scripts\python.exe' scripts/ci_local.py --l0-only # 只跑 L0(含 L0b 提交信息门禁)
& '.venv\Scripts\python.exe' scripts/ci_local.py --l05-only # 只跑 L0.5
& '.venv\Scripts\python.exe' scripts/ci_local.py --commits 20 # 提交信息校验最近 20 条
```
- **自动选用装了 pre-commit 的解释器**(优先级:`DTU_PRECOMMIT_PYTHON` 显式指定 → 当前解释器(重建 `.venv` 已自带 pre-commit)→ 仓库 `.venv` → PATH 的 `python`;显式值无效 → 硬报错并指明变量名,不静默回退);
仍不可用时退出码 2 并给出修复提示。
- **自动选用装了 pre-commit 的解释器**(优先级:`DTU_PRECOMMIT_PYTHON` 显式指定 → 当前解释器(重建 `.venv` 已自带 pre-commit)→ 仓库 `.venv` → PATH 的 `python` → **常见机器级安装位置**;显式值无效 → 硬报错并指明变量名,不静默回退);
仍不可用时退出码 2 并列出**全部已尝试路径**。
⚠️ 该候选表与 CI 侧 **`scripts/ci_find_python.ps1`** 必须**同序同步**——2026-10-11 的 L0 全灭就是
workflow 单方面把候选收窄成"PATH python + 仓库 `.venv`"(runner 上两者都是死路)导致的。
- **L0b 提交信息门禁**:与 CI 的 `ci_gitlint_range.ps1` 同语义(`--all-files` 跑不到 commit-msg stage)。
- **`.map` 自适应与 CI 完全一致**:本地有 `.map` 就照常跑 `mem-numbers`,没有就跳过。
- 退出码:`0` 通过 / `1` 门禁或单测失败 / `2` 环境错误。
@@ -567,16 +578,15 @@ CI 只在 push / PR 时跑,且需 runner 就绪。**提交前自查、或 runn
### 9.4 部署前置(需人工确认)
| 项 | 要求 |
|---|---|
| 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 跳过(不硬失败),但**等于该层裸奔**,须尽快补装 |
CI 能不能跑起来,取决于 runner 机器上四件事是否就位:**服务端开关 / runner 注册与 labels /
看门狗常驻 / 机器级 `DTU_*` 工具变量**。清单与逐项部署步骤都在
[`../CI/远程CI完整指南.md`](../CI/远程CI完整指南.md):§0.2(前置清单)、§2(注册,
`--instance` 用回环、labels 必须精确匹配 `runs-on`)、§2.4(**看门狗**——缺了它 Gitea 一重启
CI 就静默停摆,2026-10-11 实测 run 16~21 共 12 个 job 零日志)、§4.1.3(runner 以 SYSTEM 跑,
必须写 MACHINE 级变量)。
> 📖 **配置步骤 / 排障**(服务端开关、runner 注册命令、PATH 与 MSVC 路径要求、
> 内网拉不到 action 的处置、故障排查表)见
> [`../环境与部署/CI配置指南.md`](../环境与部署/CI配置指南.md)
> 与 [`../环境与部署/Gitea_Runner部署指南.md`](../环境与部署/Gitea_Runner部署指南.md)。
> ⚠️ 对测试流程只需记住一条:**runner 不在线时,`ci_local.py` 就是 L0 / L0.5 的唯一保障**。
> "CI 显示绿"与"本地门禁通过"是两回事,见 §9.3 与 §7.1。
---
@@ -615,6 +625,7 @@ CI 只在 push / PR 时跑,且需 runner 就绪。**提交前自查、或 runn
| 日期 | 变更 |
|---|---|
| 2026-10-12 | **§9 瘦身:机器侧内容迁出**(远程 CI 文档独立成 `docs/CI/`)。§9.2 两个 job 的**步骤级细节**并入 `docs/CI/远程CI完整指南.md` §5.5、§9.4 部署前置表并入其 §0.2;本文 §9 只保留**测试分层视角**的内容(CI 覆盖哪些层、两个 job 各守哪层、触发与 paths-ignore 语义、本地等价 `ci_local.py`、"CI 不替代本地门禁")。§9.1 两个 🔴 块压缩为结论 + 指针(机制根因与排障口径归 `docs/CI/`)。相关文档表两行 CI 条目合并为一行指向 `docs/CI/README.md` |
| 2026-10-10 | §4.2.1 台账:`Drivers/BSP/NETCFG/net_cfg_legacy.c` 由 `⬜ 候选` 改为 **`⛔ 已删除`**——旧档 NCFG 迁移整体移除(`net_cfg_legacy.{c,h}` 删除、`cfg_store_legacy_path()` 撤销;旧档 `/cfg/netcfg.bin` 不读、不删、不迁移,配置回落工厂默认),该测试候选随之撤销 |
| 2026-10-02 | **S6**:`test/run_all_suites.py` 新增 **`--check-build`**(只编译不烧录,纯离机,不需要 MCU/串口/ST-LINK);新增 **§3.6.1** 说明其解决的问题(测试构建无独立门禁,2026-08~09 CH395F 套件实例)、判定语义与用法。**实测 4 套件全部 `BUILD_OK: 0 errors, 0 warnings`**(即 T-4 提到的"CH395F 不可编译"已修复) |
| 2026-10-02 | **S1-b + S2**:新增 **§4.2.1「L0.5 适格模块台账」**(判据 + 模块状态表 + "两条实证":sys_clock / cfg_file 首测即各抓一处生产缺陷);§4.1 登记 `test/host/cfg_file/` 新单测。同步新增 [`配置文件单元测试说明.md`](配置文件单元测试说明.md)(含抓出的 `s_encode` 返回值误判 P0 缺陷记录) |
-371
View File
@@ -1,371 +0,0 @@
# CI 配置指南(Gitea Actions)
> **适用**:为 STM32F4-Base 部署 / 排障 CI(`.gitea/workflows/ci.yml`,L0 + L0.5)。
> **前置阅读**:`docs/测试说明/测试开发标准流程.md` §9(CI 覆盖边界与判定细节)、
> `docs/环境与部署/项目部署环境要求.md` §2.11(runner 环境要求)。
> 本文只讲**怎么配**,不重复"为什么这么设计"。
---
## ⚠️ 0.0 CI 不覆盖的体积门禁(必读,S14)
> **CI 不守 Flash/RAM 体积门禁。** `mem-numbers` 钩子的事实源是 Keil 构建产物
> `MDK-ARM/**/*.map`(已 gitignore),而 CI 刻意不跑 `@build` → **无 `.map` → 该钩子被跳过**。
>
> 后果:**新增任务 / 栈 / 缓冲 / 代码导致的体积增长(或文档内存数字失真)不会在 CI 被拦下。**
>
> 兜底(每次改代码后必做):
> ```powershell
> & '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py # 0/0 且生成 .map
> & '.venv\Scripts\python.exe' scripts/check_mem_numbers.py --scan
> ```
> 若 CI 允许在带 Keil 的 runner 上跑,则该钩子会在有 `.map` 时**照常执行**(workflow 自适应)。
>
> CI 运行时该跳过以 **`::warning::`** 明示(不是弱化的 notice),并写入 job 的
> **step-summary** 醒目行,避免被忽略。
---
## 0. 全景:CI 需要三样东西就位
```
① Gitea 服务端开关 ② Windows Runner 注册 ③ 仓库里的 workflow 文件
[actions] ENABLED=true labels 含 windows .gitea/workflows/ci.yml
│ │ │
└────────────┬───────────┴──────────────────────────────┘
▼
push / PR 触发 → runner 拉起容器/壳 → 跑 L0 + L0.5 → 结果回显到提交
```
**三者缺一,CI 就是空转的**(workflow 存在但永不执行 / 执行但拉不到 runner)。
当前仓库状态:**③ 已就绪(本机提交 `e904865`,尚未 push);① ② 需人工确认。**
---
## 1. 服务端:开启 Gitea Actions
### 1.1 确认版本与开关
Gitea **≥ 1.21** 才内置 Actions(实测本实例 **1.25.4**,满足)。以 **site admin** 登录后:
- 管理后台 → **站点管理 → 配置 / Site Administration → Configuration**,搜索 `actions`:
```ini
[actions]
ENABLED = true
```
### 1.2 编辑配置文件(推荐,永久生效)
配置文件位置(按部署方式二选一):
| 部署方式 | 配置文件路径 |
|---|---|
| 二进制 / 直接跑 | `<gitea 目录>/custom/conf/app.ini` |
| Docker | 容器内 `/data/gitea/conf/app.ini`(挂载卷里的 `custom/conf/app.ini`) |
在 `app.ini` 追加(若无 `[actions]` 段):
```ini
[actions]
ENABLED = true
```
改完 **重启 Gitea 服务**(Docker:`docker restart <容器名>`;二进制:重启进程)。
### 1.2.1 `DEFAULT_ACTIONS_URL` 是什么(**容易搞反**)
它**不是你的 Gitea 地址**,而是控制 **`uses: actions/checkout@v4` 这类 action 从哪里下载**:
| 取值 | 行为 |
|---|---|
| **不配(默认)** | 从 **[github.com](https://github.com)** 拉取 action |
| `self` | 从**你自己的 Gitea 实例**拉取(要求 action 已镜像进你的实例) |
| 具体 URL(如 `https://gitea.com`) | 从该地址拉取 |
> ⚠️ **不要把你的实例域名写进去当普通 URL**——要"从自己实例拉"就用关键字 **`self`**;
> 且**写了 `self` 还得先把 action 镜像进自己实例**,否则 `actions/checkout` 依然拉不到。
**按你的网络环境选择**:
| 场景 | 配置 |
|---|---|
| 服务器**能**访问 github.com | **什么都不配**(默认走 github.com,最省事) |
| 内网,**不能**访问 github.com | 建议**改用零依赖写法**(见 §4.3 方案 3),`DEFAULT_ACTIONS_URL` 配不配都行;<br>若坚持用 `actions/checkout@v4`,则需 `DEFAULT_ACTIONS_URL = self` **且**把该 action 镜像进实例 |
> Gitea 官方 FAQ 原文:*"From `https://github.com` by default, or from your own instance
> when `[actions].DEFAULT_ACTIONS_URL` is `self`."* 使用别处 action 也可在 workflow 里直接写全路径:
> `uses: https://gitea.com/owner/repo@ref` / `uses: self:owner/repo@ref`。
### 1.3 验证
浏览器打开仓库地址,顶部/侧栏应出现 **Actions** 标签页。
若仍提示"此仓库未启用 Actions",说明开关没生效,回到 1.1 检查。
> ⚠️ 若实例装在**内网且不能访问公网**,`actions/checkout@v4` 这类官方 action 会拉不下来。
> 处置见 §4.3。
---
## 2. Runner 端:注册 Windows Runner
CI 的两个 job 都是 `runs-on: windows`,所以**必须有一个 labels 含 `windows` 的 runner**。
推荐用 `act_runner`(Gitea 官方 runner)。
### 2.1 获取注册令牌
- **仓库级 runner**(推荐,只服务本仓库):
仓库 → **设置 / Settings → Actions → Runners → 创建 Runner**,复制页面显示的
**Registration Token**(形如 `xxxYYYzzz...`)。
- **全局 runner**(服务整个实例):站点管理 → Actions → Runners。
记下 Gitea 地址,例如 `https://gitea.bitnasdaq.vip`。
### 2.2 下载并注册 act_runner
```powershell
# 1) 下载(示例版本,请取 releases 页最新 Windows amd64)
# https://gitea.com/gitea/act_runner/releases
Invoke-WebRequest -Uri "https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-windows-amd64.exe" `
-OutFile "C:\act_runner\act_runner.exe"
# 2) 注册:URL 用 Gitea 根地址,token 用 2.1 复制的
cd C:\act_runner
.\act_runner.exe register `
--no-interactive `
--instance "https://gitea.bitnasdaq.vip" `
--token "粘贴 Registration Token" `
--name "win-stm32-ci" `
--labels "windows:host"
```
**关键点**:`--labels "windows:host"` 中的 `windows` 必须**逐字**与 workflow 的
`runs-on: windows` 对应(Gitea runner 的 label 名是自定义的,默认是 `ubuntu-latest`,
**不注册 `windows` 这个 label 就永远匹配不到 job**)。
> `:host` 表示直接跑在宿主 Windows 上(不是容器)。本场景**必须用 `host`**——
> 容器里拿不到宿主已装的 Keil/MSVC/cppcheck,且 Windows 容器支持很差。
### 2.3 配置 `config.yaml`(可选但建议)
`C:\act_runner\config.yaml` 里确认/补充:
```yaml
runner:
labels:
- "windows:host" # 与 register 一致
capacity: 1 # 并行 job 数;单机建议 1,避免 cppcheck 抢 CPU
timeout: 30m
cache:
enabled: true
```
### 2.4 以服务方式常驻
```powershell
# 注册为 Windows 服务(管理员权限)
.\act_runner.exe daemon install
.\act_runner.exe daemon start
# 或前台调试(先看日志通不通,推荐首次这样跑)
.\act_runner.exe daemon
```
注册成功后,回到 **仓库 → Settings → Actions → Runners**,应看到 `win-stm32-ci`
状态为 **Idle / 绿色**。
---
## 3. Runner 软件环境:装什么
runner 是一台干净的 Windows,**下面这些必须装**,否则 job 会失败或降级跳过。
| 项 | 必需性 | 影响 | 安装 |
|---|---|---|---|
| **Python 3.x** | 🔴 必需 | `l0-gates` 环境自检失败 → **job 硬失败** | `winget install Python.Python.3.12` |
| **cppcheck** | 🔴 必需 | 同上(`cppcheck --version` 探测不过) | `winget install Cppcheck.Cppcheck` |
| **pre-commit** | 🔴 必需 | 同上(`python -m pre_commit --version` 探测) | `pip install pre-commit` 或 `uv tool install pre-commit` |
| **MSVC(VS + Windows SDK)** | 🟡 建议 | 缺则 `l05-host-unit-tests` **告警降级跳过**(不硬失败,但 L0.5 裸奔) | `winget install Microsoft.VisualStudio.2022.BuildTools`,勾"使用 C++ 的桌面开发" |
| Keil / ST-LINK | ⛔ 不需要 | CI 刻意不跑 `@build` | — |
| 开发板 / 串口 | ⛔ 不需要 | L1/L2/L3 不上 CI | — |
### 3.1 关键:PATH 必须对
CI 的环境自检用的是 `Get-Command python` / `cppcheck`,**依赖 PATH**。
装完后**新开一个终端**(或重启 runner 服务)让 PATH 生效,然后自检:
```powershell
python --version # 期望 Python 3.x
cppcheck --version # 期望 Cppcheck 2.21.0 或更高
python -m pre_commit --version
```
### 3.2 MSVC 的路径要求(探测逻辑)
`l05-host-unit-tests` 的 MSVC 探测优先读机器级 `DTU_MSVC_CL`(x64 `cl.exe` 完整路径;
值无效 → 硬失败、不静默回退);未设置时按**标准布局自动探测**(根目录由
`%ProgramFiles` / `%ProgramFiles(x86)` 推导,不写死盘符;与
`test/host/common/unit_test_harness.py` 的 `_default_vs_roots()` 保持一致):
2019 → `Program Files (x86)`;2022 / 2026(实际目录名 `18`)→ `Program Files`,
各取 BuildTools/Community/Professional/Enterprise 子目录,找
`VC\Tools\MSVC\*\bin\Hostx64\x64\cl.exe`。
> ⚠️ **装在完全非标准位置(如 D 盘自定义目录)会探测不到** → 被当成"没装 MSVC"跳过 L0.5(仅告警)。
> 处置(二选一):设机器级 `DTU_MSVC_CL` 指向 `cl.exe`(推荐;值无效会硬报错),
> 或把 VS 装回标准位置(探测按 `%ProgramFiles*` 推导,换盘符的标准布局仍命中)。
>
> Windows SDK 同理:默认 `<ProgramFiles(x86)>\Windows Kits\10\`;非标准位置用 `DTU_WINDOWS_KITS_ROOT` 指定。
> 📌 2026-10-02:workflow 与本地 harness 同步改为"变量优先 + 标准布局推导"
> (`本地工具路径环境变量方案.md`)——旧写法要求"同步改两处",现已不需要。
---
## 4. 仓库端:workflow 文件
### 4.1 文件位置与提交
workflow 必须位于**仓库根**的 `.gitea/workflows/` 下(不是 `.github/`):
```
<仓库根>/
└─ .gitea/
└─ workflows/
└─ ci.yml ← 本仓库已有,见该文件
```
**它必须被 push 到远程才能生效**——目前本仓库的 `e904865` 还在本地
(`origin/v2-on-ui` 尚未包含),所以**现在 push 之前 CI 不会跑**:
```powershell
git push origin v2-on-ui # ⚠️ 需你明确要求才推送(本仓库约定:默认不推送)
```
### 4.2 触发条件
```yaml
on:
push:
paths-ignore: ['docs/**', '**/*.md', '.workbuddy-ai/**']
pull_request:
paths-ignore: ['docs/**', '**/*.md', '.workbuddy-ai/**']
workflow_dispatch: # 页面可手动触发
```
- 纯文档改动**不触发**(省 runner);
- 想手动跑一次:仓库 → Actions → 选 `CI` → **Run workflow**。
### 4.3 内网无公网时的 action 拉取(重要)
workflow 用了 `actions/checkout@v4`。**默认从 `github.com` 拉取**(见 §1.2.1);实例不通
github.com 会在 checkout 步骤卡住/失败。三种处置:
1. **镜像到自有实例**:`DEFAULT_ACTIONS_URL = self`,**并先把 `actions/checkout` 导入你自己的 Gitea**;
2. **指向可达镜像**:`DEFAULT_ACTIONS_URL` 指向某个能访问的 action 站(或在 workflow 里
直接写全路径 `uses: https://<host>/owner/repo@ref`);
3. **零依赖**(内网最稳,推荐):把 checkout 换成纯 git 命令,不依赖任何 action:
```yaml
- name: Checkout
shell: pwsh
run: |
git init .
git remote add origin $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git
git fetch --depth 1 origin $env:GITHUB_SHA
git checkout $env:GITHUB_SHA
```
> 方案 3 无需任何外部 action 与任何 `DEFAULT_ACTIONS_URL` 配置;代价是失去 checkout 的缓存优化。
> 参考:Gitea FAQ「Where will the runner download scripts when using actions such as `actions/checkout@v4`?」
### 4.4 产物存档与结果汇总(S3,2026-10-02)
**目标**:CI 失败时**不查 MySQL / 不翻 runner 磁盘**,在 Gitea 页面即可看到完整输出与摘要。
**零依赖方案**(内网首选,**不用 `actions/upload-artifact`**):
| 手段 | 说明 |
|---|---|
| `Tee-Object` 落盘 | 两个 job 把关键输出 `Tee` 到 `ci-artifacts/l0-pre-commit.log`、`ci-artifacts/l05-<模块>.log`(**留在 runner 工作区**,已 gitignore) |
| `$GITHUB_STEP_SUMMARY` | 写入 job 的 **step-summary**(Gitea UI 运行页直接可见,无需下钻日志) |
| `::notice::` 汇总行 | 控制台醒目行,便于日志检索 |
**汇总行格式**:
| 层 | 汇总行 |
|---|---|
| L0 | `L0: pre-commit hooks -- passed=N failed=M skipped=K (log: ci-artifacts\l0-pre-commit.log)` |
| L0.5 | `L0.5: <K> runner(s), <A>/<B> assertions passed, fail=<F>`(逐模块另有 `::notice::<模块>: N/M PASSED (failed=0)`) |
> ⚠️ **`ci-artifacts/` 已加入 `.gitignore`**:它是 runner 工作区的运行时产物,不要提交。
>
> ⚠️ **若后续改为 `actions/upload-artifact`**(需确认实例能拉该 action):把 `Tee` 落盘
> 保留即可,上传步骤叠加在其后;本方案的落盘文件即上传源。
**PowerShell 陷阱(已在 workflow 内处理)**:runner 注入 `$ErrorActionPreference='Stop'`,
而 `& native 2>&1 | Tee-Object` 会把 native 的 stderr 变成 ErrorRecord → 在 `Stop` 下**整段中止**。
故 pipeline 前后需 `Save/Restore` 该偏好为 `Continue`(与"环境自检"步骤同法)。
`$LASTEXITCODE` 在 `Tee-Object` 之后**仍保留** native 退出码(实测),可直接用于判定。
---
## 5. 端到端验证清单
按顺序确认,任一步不过就停在那里排障:
| # | 检查 | 期望 |
|---|---|---|
| 1 | 仓库顶部有 **Actions** 标签页 | 服务端开关已开 |
| 2 | Settings → Actions → Runners 里 runner 为 **Idle** | runner 注册成功 |
| 3 | runner 上 `python --version` / `cppcheck --version` / `python -m pre_commit --version` | 三条全通 |
| 4 | `git push` 一次(或页面 Run workflow) | Actions 页出现运行中任务 |
| 5 | `l0-gates` 日志 | `pre-commit ... Passed`;`::notice::L0: pre-commit hooks -- passed=.. failed=0 skipped=..`;无 `.map` 时见 `::warning::` 体积门禁未跑 |
| 6 | `l05-host-unit-tests` 日志 | `::notice::L0.5: 2 runner(s), 139/139 assertions passed, fail=0` |
| 7 | job 运行页的 **Summary** 区块 | 出现 "L0 static gates" / "L0.5 host-side unit tests" 小标题与汇总行 |
| 8 | 提交记录旁出现 ✅ | CI 全绿 |
**本地先自查**(与 CI 同一套判定,避免推上去才发现问题):
```powershell
& '.venv\Scripts\python.exe' scripts/ci_local.py
```
---
## 6. 常见故障排查
| 现象 | 根因 | 处置 |
|---|---|---|
| push 后 Actions 页**空白/无任务** | ① 服务端开关未开 ② workflow 没 push 到远程 ③ 只改了 `docs/**`(被 `paths-ignore` 滤掉) | 逐条核对 §1 / §4.1 / §4.2 |
| 任务一直 **Waiting / 排队** | 没有 label 匹配 `windows` 的 runner | 检查 `--labels "windows:host"`;Settings→Runners 是否 Idle |
| `l0-gates` 报 `::error::runner 缺少:Python, cppcheck` | runner 没装或不在 PATH | 装完后**重启 runner 服务**(新进程才吃到新 PATH) |
| `checkout` 步骤卡住/超时 | 拉不到 `actions/checkout@v4`(内网) | 见 §4.3 |
| `l05` 日志出现 `::warning::runner 未安装 MSVC` | VS 装在非默认路径或未装 | 见 §3.2(默认路径 / 同步改两处根目录) |
| `mem-numbers` 显示 `Skipped` | 正常(CI 无 Keil,无 `.map`) | 无需处理;但注意 **CI 未守体积门禁**(见 §0.0),改代码后本地 `@build` + `check_mem_numbers.py --scan` 兜底 |
| L0.5 显示 `2 runner(s), X/Y ...` 但 X<Y | 有单测用例未通过 | 看该模块的 `::error::unit test failed` 行与其 `ci-artifacts/l05-<模块>.log` |
| L0.5 报 `produced no '=== ... N/M PASSED ===' summary line` | 该模块运行器未输出汇总行(改了 harness 或运行器崩溃) | 核对 `test/host/common/unit_test_harness.py` 的 `summary_marker` 与该模块 `TestBuild` 是否一致 |
| `Tee-Object` 步骤报 `TerminatorExpectedAtEndOfString` / 字符串缺少终止符 | `run:` 块混入了非 ASCII(GBK 解码错位) | 见文件头 🔴🔴;`run:` 内必须纯 ASCII |
| `l05` 报 `fatal error C1083: 无法打开包括文件 "stdio.h"` | Windows SDK 缺失 | 装 VS 时勾"使用 C++ 的桌面开发"+ SDK |
| 单测退出码 2 | 环境错误(多为无 MSVC) | 同上 |
| `pre-commit` 首次运行久 | 首次要下载各钩子的环境 | 正常,runner 缓存后会快 |
---
## 7. 日常维护
- **升级 runner**:停服务 → 换 `act_runner.exe` → 起服务(注册信息保留在 `config.yaml`);
- **改了 `VS_ROOTS` / 工具链探测逻辑**:记得**同步改 workflow 里的探测根目录**(两处一致);
- **钩子集变化**(`.pre-commit-config.yaml` 增删钩子):CI 自动跟随,无需改 workflow;
- **`@build` / L1 / L2**:永远留在本地,不要往 CI 加(需 Keil 授权与真机);
- **runner 长时间不可用**时:CI 只是"多一道网",**提交前的本地 `ci_local.py` 仍是 L0 的唯一保障**,
不能因"已经配了 CI"放宽(见 `docs/待处理内容.md` 的部署前置警示块)。
---
## 变更记录
| 日期 | 变更 |
|---|---|
| 2026-10-02 | **S3 + S14**:新增 §0.0「CI 不覆盖的体积门禁」醒目声明;新增 §4.4「产物存档与结果汇总」(零依赖 `Tee-Object` + `$GITHUB_STEP_SUMMARY` + `::notice::` 汇总行,不用 `actions/upload-artifact`;记录 `$ErrorActionPreference='Stop'` 与 `2>&1 \| Tee-Object` 的陷阱及处置)。§5 验证清单(7→8 条)与 §6 排障表同步更新 |
| 2026-10-01 | **修正 `DEFAULT_ACTIONS_URL` 说明**(原 §1.2 示例误导):新增 §1.2.1 明确它控制的是 **action 下载源**(默认 `github.com`;`self` = 从自己实例拉且需先镜像 action),**不是填自己的 Gitea 地址**;§4.3 同步修正(原文误写"gitea.com 代理"),并补零依赖写法为内网首选。依据:Gitea 官方 FAQ + config-cheat-sheet |
| 2026-10-01 | 初版:Gitea Actions 配置全流程(服务端开关 / Windows runner 注册 / runner 软件环境 / workflow 位置与触发 / 内网 action 拉取 / 端到端验证清单 / 故障排查 / 日常维护) |
@@ -1,917 +0,0 @@
# Gitea Actions Runner 部署指南(本机 Windows 原生版)
> **适用环境(2026-10-02 实测确认)**:
> - Gitea **1.25.4**,**Windows 原生二进制**(`D:\Docker\Gitea\gitea.exe`,**不是 Docker**),
> 以 **Windows 服务**运行(`tasklist` 显示 Session = `Services`),监听 `0.0.0.0:3001`
> - 配置 `D:\Docker\Gitea\custom\conf\app.ini`,`[actions] ENABLED = true` **已开启** ✅
> - 数据库 MySQL(`127.0.0.1:3306`),仓库根 `D:\Docker\Gitea\data\gitea-repositories`
> - 对外域名 `http://gitea.bitnasdaq.vip/`(经反代),本地直连 `http://127.0.0.1:3001` ✅
> - Docker Desktop **已装但 daemon 未运行** → **本指南不使用容器,全程原生**
>
> **本文只讲 Runner 端**(下载 → 注册 → 配置 → 常驻 → 验证 → 排障)。
> Gitea 服务端开关已就绪,无需再动。CI 整体设计见 `docs/测试说明/测试开发标准流程.md` §9。
> ⚠️ **重要版本变更(2026-10-02 实测)**:act_runner 已更名为 **`gitea-runner`**,
> 当前最新版 **v4.0.1**(发布页 `https://gitea.com/gitea/runner/releases`,
> 注意仓库路径已从 `gitea/act_runner` 变为 **`gitea/runner`**)。
> **v4 移除了 `daemon install/start/stop/status` 子命令**(那些是 v0.2.x 的接口)。
> 另:v4 的 `log` 段只有 `level` 和 `job`(**无 `file` 键**),配置用
> `gitea-runner config init / set / get / generate` 管理。
> 🔴 **常驻方式修正(2026-10-02 实测推翻)**:`gitea-runner daemon` 是**纯控制台程序**,
> **不支持作为 Windows 服务运行**(二进制内无服务管理代码,也无 `service.go`);
> 用 `sc.exe` / `New-Service` 注册后 `Start-Service` 必然失败。
> **正确方式是 Windows 任务计划程序** —— 见 **§4**。
---
## 0. 先理解:这台机器上的拓扑
**Gitea 和 Runner 在同一条机器上** —— 这是本部署最关键的事实:
```
┌─────────────────────────── 本机 (Windows) ────────────────────────────┐
│ │
│ gitea.exe ── Windows 服务 ── 监听 0.0.0.0:3001 │
│ ▲ │
│ │ ① runner 注册 / 心跳 / 领任务:走 http://127.0.0.1:3001 │
│ │ (本地回环,不经公网、不依赖 DNS/反代) │
│ │ │
│ gitea-runner.exe ── 计划任务 GiteaActionsRunner(win-stm32-ci) │
│ │ │
│ └─ ② 领到 job 后,直接在**本机**跑: │
│ git fetch / pre-commit / cppcheck / cl.exe │
│ (标签 windows:host = 用宿主环境,非容器) │
│ │
│ 外部用户 → https://gitea.bitnasdaq.vip → 反代 → 127.0.0.1:3001 │
└───────────────────────────────────────────────────────────────────────┘
```
**两个地址要分清**(最容易搞错的地方):
| 用途 | 地址 | 说明 |
|---|---|---|
| **Runner 连接 Gitea** | `http://127.0.0.1:3001` | 本机回环,**最稳**。用 `gitea.bitnasdaq.vip` 也行,但会多绕一圈 DNS + 反代,且若反代没配对内网 IP 会失败 |
| **用户在浏览器访问** | `https://gitea.bitnasdaq.vip` | 走反代;注册时网页上显示的示例命令用的是这个域名,**你要手动换成 127.0.0.1:3001** |
> ⚠️ **注册时用哪个地址,决定了 runner 以后一直连哪里**。
> 注册信息存在 `.runner` 里,之后改起来要重新注册,**建议一开始就用 `http://127.0.0.1:3001`**。
---
## 1. 下载 gitea-runner
### 1.1 选对版本
| 项 | 值 |
|---|---|
| 架构 | **amd64**(`PROCESSOR_ARCHITECTURE = AMD64`,实测) |
| 版本 | **v4.0.1**(2026-10-02 实测最新稳定版) |
| 下载页 | https://gitea.com/gitea/**runner**/releases ← 注意仓库名已从 `act_runner` 改为 `runner` |
| 文件名 | `gitea-runner-4.0.1-windows-amd64.exe` ← 二进制名也从 `act_runner` 改为 **`gitea-runner`** |
### 1.2 下载
```powershell
# 建目录(与 Gitea 同级,便于管理)
New-Item -ItemType Directory -Force -Path "D:\Docker\act_runner" | Out-Null
cd "D:\Docker\act_runner"
# 下载 v4.0.1(版本号请替换为 releases 页当前最新)
Invoke-WebRequest -Uri "https://gitea.com/gitea/runner/releases/download/v4.0.1/gitea-runner-4.0.1-windows-amd64.exe" `
-OutFile "gitea-runner.exe"
# 校验能跑
.\gitea-runner.exe --version
```
**期望输出**:`gitea-runner version v4.0.1`。
> 💡 **本机实测**(2026-10-02):下载 20.6 MB,耗时约 5 秒,`gitea.com` 可直连(`HTTP 200`)。
### 1.3 v4.0.1 的命令集(与 v0.2.x 的差异)
```
Available Commands:
bug-report Print information useful when filing a bug report
cache-server Start a cache server for the cache action
config Generate, read and edit config files
daemon Run as a runner daemon
exec Run workflow locally.
help Help about any command
register Register a runner to the server
```
> ⚠️ **没有 `daemon install/start/stop/status`** —— 这些是 v0.2.x 的接口,v4 已移除。
> 常驻必须用 Windows 原生方式(见 §4)。
>
> `config` 子命令很方便,建议优先用它而不是手改 YAML:
> `config init`(生成最小配置)/ `config generate`(打印全部选项)
> / `config set <key> <value>` / `config get <key>`。
---
## 2. 注册 Runner
### 2.1 先拿 Registration Token
1. 浏览器登录 `https://gitea.bitnasdaq.vip`
2. 进仓库 **`wandering1/STM32F4-Base`**
3. **Settings(设置)→ Actions → Runners → 创建 Runner / Create new Runner**
4. 页面会显示一段命令,形如:
```
./gitea-runner register --no-interactive --instance https://gitea.bitnasdaq.vip --token xxxxxxxxxxxxxxxxxxxx
```
**复制其中的 `--token` 后面那一串**(这是 Registration Token,一次性用途)。
页面上的 `--instance` 值**不要照抄**——见下。
> 💡 **Token 存在于 `action_runner_token` 表**(MySQL,见 `Gitea_Runner部署指南` §附录)。
> 每仓库一个有效 token,**注册一次后即被消费**;过期(约 1 小时)或已用则需重新创建。
### 2.2 执行注册(关键:instance 用 127.0.0.1)
```powershell
cd "D:\Docker\act_runner"
.\gitea-runner.exe register `
--no-interactive `
--instance "http://127.0.0.1:3001" `
--token "粘贴刚才复制的 Registration Token" `
--name "win-stm32-ci" `
--labels "windows:host"
```
**四个参数逐一说明**:
| 参数 | 值 | 为什么 |
|---|---|---|
| `--instance` | `http://127.0.0.1:3001` | **本机回环**。因为 Gitea 就在这台机器上,回环最稳、最快、不受反代/DNS 影响 |
| `--token` | 网页复制的那串 | 绑定到具体仓库(仓库级 runner) |
| `--name` | `win-stm32-ci` | runner 显示名,随意但建议有辨识度 |
| `--labels` | **`windows:host`** | 🔴 **最容易踩的坑**:`windows` 必须与 workflow 的 `runs-on: windows` **逐字一致**;`:host` 表示用**宿主环境**直接跑(非容器)——本场景**必须 host**,容器里拿不到 MSVC/cppcheck |
> ⚠️ **`--labels` 写错的后果**:runner 会注册成功、显示绿色,但**任务永远排队不执行**
> (没有任何 runner 匹配 `runs-on: windows`)。这是最隐蔽的失败模式。
**期望输出**:`Runner registered successfully`,并在 `D:\Docker\act_runner\` 生成 `.runner` 文件(记录注册凭据)。
### 2.3 验证注册
浏览器刷新 **Settings → Actions → Runners**,应看到:
| 列 | 期望值 |
|---|---|
| Name | `win-stm32-ci` |
| Labels | `windows`(`:host` 是执行方式,UI 可能只显示 `windows`) |
| Status | 🟢 **Idle** |
---
## 3. 配置 config.yaml
**推荐用 `config` 子命令生成/修改**(v4 新增,比手改 YAML 安全):
```powershell
cd "D:\Docker\act_runner"
.\gitea-runner.exe config init # 生成最小 config.yaml
.\gitea-runner.exe config set runner.capacity 1 # 逐项设置
.\gitea-runner.exe config set runner.timeout 30m
.\gitea-runner.exe config set cache.enabled true
.\gitea-runner.exe config set log.level info
.\gitea-runner.exe config get runner.capacity # 校验
.\gitea-runner.exe config generate # 打印全部可用选项(含注释)
```
**本机已落地的配置**(`D:\Docker\act_runner\config.yaml`):
```yaml
log:
# trace/debug/info/warn/error/fatal/panic;排障时临时改 debug
level: info
# 每个 job 的任务日志另存一份到本地(防上报 Gitea 失败时日志丢失)
job:
dir: "D:\\Docker\\act_runner\\log\\job"
retention: 168h
max_size: 100MB
runner:
file: .runner # 注册凭据(由 register 生成,勿手改)
capacity: 1 # 并行 job 数;单机建议 1(cppcheck 吃 CPU)
timeout: 30m0s # 单 job 超时;L0+L0.5 约 1~3 分钟
fetch_timeout: 5m0s
fetch_interval: 2s
# 🔴 标签:windows 必须与 workflow 的 runs-on: windows 逐字一致
labels:
- "windows:host"
cache:
enabled: true
dir: "D:\\Docker\\act_runner\\cache"
container:
network: ""
privileged: false
options: ""
valid_volumes: []
docker_host: ""
force_pull: false
```
> ⚠️ **v4 的 `log` 段只有 `level` 和 `job` 两个键** —— **没有 `file` 键**
> (`config set log.file ...` 会报 `unknown config key`)。
> 想要本地日志用 `log.job.dir`(按 job 落文件),或把服务输出重定向到文件。
>
> ⚠️ **改了 `runner.labels` 必须重启 runner 才生效**
> (Gitea 官方 FAQ:*"A runner declares its labels every time it starts ... so change them there and restart the runner."*)
---
## 4. 让 Runner 常驻(任务计划程序)
### 4.0 🔴 关键结论:**不能用 Windows 服务**
> **本条为 2026-10-02 实测推翻既有假设后的修正结论,务必先读。**
先前本文档(及 `install_service.ps1`)主张用 `sc.exe` / `New-Service` 把
`gitea-runner daemon` 注册成 Windows 服务。**实测不可行**,`Start-Service` 必然失败:
```
Start-Service : 由于以下错误无法启动服务"Gitea Actions Runner (gitea-runner)" ...
```
**根因(对 `gitea-runner.exe` v4.0.1 二进制逆向确认)**:
| 证据 | 结论 |
|---|---|
| 命令集只有 `bug-report` / `cache-server` / `config` / `daemon` / `exec` / `register`(对应源码 `internal/app/cmd/{bug_report,cache-server,cmd,config,daemon,exec,register}.go`) | **没有 `service.go`,没有任何服务管理子命令** |
| `daemon --help` 仅 `--labels` / `--once` 两个 flag | daemon 无服务模式开关 |
| 二进制内虽出现 `OpenSCManagerW` / `CreateServiceW` / `StartServiceCtrlDispatcherW` 等符号 | 它们来自 **Docker SDK**(容器作业路径),**不是** runner 的服务安装代码——切勿据此误判 |
**`daemon` 是纯控制台程序**:它从不调用 `StartServiceCtrlDispatcher`,
因此 SCM 启动它后收不到 `SERVICE_RUNNING` 回报,判定超时 → 报错 1053。
**正确做法:Windows 任务计划程序(Task Scheduler)。**
它是控制台程序常驻的标准手段,功能上等价于服务:
| 服务能做的 | 任务计划程序对应能力 |
|---|---|
| 开机自启 | 触发器 `-AtStartup`(+ 延迟 30s 让 Gitea 先起) |
| 不登录也运行 | 以 `SYSTEM` 账户运行 |
| 最高权限 | `-RunLevel Highest` |
| 崩溃自动重启 | `-RestartCount 999 -RestartInterval 1min` |
| 开机/手动启停 | `Enable/Disable/Start/Stop-ScheduledTask` |
### 4.1 前台先试跑(强烈建议)
**先别急着常驻**——用前台模式确认能连上:
```powershell
cd "D:\Docker\act_runner"
.\gitea-runner.exe daemon --config .\config.yaml
```
看到这两行即成功:
```
level=info msg="Starting runner daemon"
level=info msg="runner: win-stm32-ci, with version: v4.0.1, with labels: [windows], declare successfully"
```
**让这个窗口开着**,去 Gitea 页面确认 runner 是 Idle,然后 Ctrl+C 停掉、再做常驻。
### 4.2 一键部署脚本(推荐)
管理员 PowerShell 执行:
```powershell
powershell -ExecutionPolicy Bypass -File "D:\Docker\act_runner\deploy_runner_task.ps1"
```
脚本流程:提权检查 → 前置文件检查(`gitea-runner.exe` / `config.yaml` / `.runner`)
→ **清理旧的无效 Windows 服务**(若存在)→ 生成启动包装 `run_runner.cmd`
→ `Register-ScheduledTask`(开机+登录双触发器、SYSTEM 账户、最高权限、崩溃重启)
→ 启动任务 → 校验进程存活。
### 4.3 手动等价命令
不想用脚本时,管理员 PowerShell 逐条执行:
```powershell
$Dir = "D:\Docker\act_runner"
# ① 包装脚本(固定工作目录 + stdout 落盘)
@"
@echo off
cd /d "$Dir"
"$Dir\gitea-runner.exe" daemon --config "$Dir\config.yaml" >> "$Dir\log\runner-stdout.log" 2>&1
"@ | Out-File "$Dir\run_runner.cmd" -Encoding ASCII
# ② 注册任务
$action = New-ScheduledTaskAction -Execute "$Dir\run_runner.cmd" -WorkingDirectory $Dir
$trgBoot = New-ScheduledTaskTrigger -AtStartup
$trgBoot.Delay = 'PT30S'
$trgLogon = New-ScheduledTaskTrigger -AtLogOn
$prin = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$sets = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-StartWhenAvailable -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit (New-TimeSpan -Seconds 0) -MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName "GiteaActionsRunner" -Action $action `
-Trigger @($trgBoot, $trgLogon) -Principal $prin -Settings $sets `
-Description "Gitea Actions Runner for STM32F4-Base CI (labels: windows:host)"
# ③ 启动
Start-ScheduledTask -TaskName "GiteaActionsRunner"
```
> ⚠️ **`ExecutionTimeLimit = 0`(不限时长)必须显式设置**——
> 任务计划程序默认 72 小时强制结束任务,会让 runner 每 3 天被杀一次。
> ⚠️ **`-WorkingDirectory` 只对 `New-ScheduledTaskAction` 有效**(这是它区别于
> WinPS 5.1 `New-Service` 的地方)。不过仍建议用 `.cmd` 包装再 `cd /d`,
> 因为 SYSTEM 账户的默认工作目录不可控。
### 4.4 任务管理速查
```powershell
Get-ScheduledTask -TaskName GiteaActionsRunner # 状态
Get-ScheduledTaskInfo -TaskName GiteaActionsRunner # 上次运行结果
Start-ScheduledTask -TaskName GiteaActionsRunner # 启动
Stop-ScheduledTask -TaskName GiteaActionsRunner # 停止
Disable-ScheduledTask -TaskName GiteaActionsRunner # 禁用自启
Enable-ScheduledTask -TaskName GiteaActionsRunner # 恢复自启
Unregister-ScheduledTask -TaskName GiteaActionsRunner -Confirm:$false # 完全卸载
```
改完 `config.yaml` 后需 **Stop 再 Start** 任务才生效。
> 💡 **`LastTaskResult` 数值解读**(别被非 0 吓到):
>
> | 值 | 含义 |
> |---|---|
> | `267009` (`0x41301`) | ✅ **`SCHED_S_TASK_RUNNING` —— 任务正在运行,不是错误**(常驻任务常态值) |
> | `0` (`0x0`) | ✅ 上次运行正常结束 |
> | `267011` (`0x41303`) | 任务尚未运行过 |
> | `267014` (`0x41306`) | 任务被用户终止 |
>
> **判断 runner 是否健康,看这三项而不是 `LastTaskResult`**:
> ① `Get-Process gitea-runner` 有进程;
> ② `log\runner-stdout.log` 最新一行是 `declare successfully`;
> ③ Gitea 页面 runner 显示 **Idle**(或 MySQL 查 `action_runner.last_online`)。
### 4.5 ⚠️ 清理遗留的无效服务
若早前按旧文档建过服务,删掉它(留着只会误导):
```powershell
Stop-Service gitea-runner -Force -ErrorAction SilentlyContinue
sc.exe delete gitea-runner
```
> **`gitea-runner` 这个名字将不再作为服务存在。** 之后提到"gitea-runner"
> 一律指**计划任务 `GiteaActionsRunner`** 或**进程 `gitea-runner.exe`**。
### 4.6 ⚠️ 权限要求(本机实测的硬约束)
**注册计划任务必须用「提权的管理员」Shell**。本机实测:
| 检查项 | 结果 |
|---|---|
| 当前用户 | `DESKTOP-KM0OG7Q\Administrator` |
| 是否提权(`IsInRole(Administrator)`) | **False** |
| 非提权下注册任务 | ❌ `拒绝访问`(`Register-ScheduledTask` 需要 elevated token) |
> **"是 Administrator 用户" ≠ "有管理员权限"** —— Windows 的 UAC 会把普通登录会话降权。
> 必须**右键 PowerShell → 以管理员身份运行**,标题栏会显示「管理员」字样。
> 💡 计划任务以 **`SYSTEM`** 账户运行,拥有完整权限,可正常访问本地
> `127.0.0.1:3001` 的 Gitea。
---
## 5. Runner 必须装齐的软件
Runner 会**直接在本机跑** CI 步骤,所以本机必须有一套工具链。
(**你本机是开发机,大概率已经全有** —— 逐条核对下表。)
| 项 | 检查命令 | 期望 | 缺了怎么办 |
|---|---|---|---|
| **Python 3.x** | `python --version` | `Python 3.x` | `winget install Python.Python.3.12` |
| **cppcheck** | `cppcheck --version` | `Cppcheck 2.21.0` | `winget install Cppcheck.Cppcheck` |
| **pre-commit** | `python -m pre_commit --version` | `pre-commit 4.x` | `pip install pre-commit` |
| **MSVC** | 见下 | `cl.exe` 存在 | `winget install Microsoft.VisualStudio.2022.BuildTools`(勾"使用 C++ 的桌面开发") |
| Git | `git --version` | 任意 | 你已在用 |
**本机实测状态(2026-10-02)**:
```
Python : C:\ProgramData\anaconda3\python.exe → 3.12.4 ✅
cppcheck : C:\Program Files\Cppcheck\cppcheck.exe → 2.21.0 ✅
pre-commit: 4.6.0 (anaconda3) ✅
MSVC : VS2019 BuildTools 14.29.30133 (Hostx64\x64\cl.exe) ✅
```
**→ 四项全齐,无需额外安装。**
### 5.1 ⚠️ 关键坑:PATH 与运行账户
**问题的本质**:计划任务以 **`SYSTEM`** 账户运行时,继承的是**系统级 PATH**
(注册表 `HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment` 的 `Path`),
**不含任何用户级 PATH**。
- 你在自己的终端里能用 `python`,**不代表 SYSTEM 账户里也能用**;
- **装完任何新软件 / 改过系统 PATH 后,必须重启 runner 任务**:
```powershell
Stop-ScheduledTask -TaskName GiteaActionsRunner
Start-ScheduledTask -TaskName GiteaActionsRunner
```
> **核对方法**:用 `check_env.ps1` 的「系统 PATH 视角」检查项,
> 或以 SYSTEM 身份跑一次 `where python` 验证。
### 5.1.1 ⚠️⚠️ 关键坑:`pre-commit` 必须在「全局 site-packages」,装到用户态等于没装
这是 2026-10-02 排查 **L0 job 反复失败**(`::error::no Python with a SYSTEM-visible pre-commit`)的真正根因,
也是本环境最隐蔽的一个坑。
**现象**:`pre-commit` 在你自己的终端里好好的(`pre-commit --version` 有输出),
但 CI 的 `l0-gates` job 三个候选 Python 全部探测失败:
```
::error::no Python with a SYSTEM-visible pre-commit. Tried:
C:\msys64\ucrt64\bin\python.exe |
<workdir>\.venv\Scripts\python.exe |
C:\ProgramData\anaconda3\python.exe
```
> 📌 上为 2026-10-02 的历史报错原文。workflow 现已改为
> **`DTU_PRECOMMIT_PYTHON`(机器级)→ PATH 的 `python` → 工作区 `.venv`** 顺序(§5.1.3),
> 且报错行会指明是变量无效还是自动发现落空。
**根因(两层)**:
1. **用户态 site-packages 对 SYSTEM 不可见**。
`pip install pre-commit`(不带提权)默认装到 **user-site**:
`C:\Users\Administrator\AppData\Roaming\Python\Python312\site-packages`。
而 SYSTEM 账户的 `%APPDATA%` 是
`C:\Windows\system32\config\systemprofile\AppData\Roaming` —— **完全是另一个目录**,
它根本看不到 Administrator 的 user-site。**任何 `python.exe` 路径都救不了这个**。
2. **pip 的「已满足」判定陷阱**(更阴)。
就算你**提权**执行 `pip install --no-user pre-commit`,pip 在判断
「这个包是不是已经装了」时看的是**整个 `sys.path`**——其中包含 user-site。
既然 user-site 里已有同版本的包,pip 会直接打印
`Requirement already satisfied: xxx in ...\roaming\...\site-packages`
然后**完全跳过**安装到全局目录。
结果:pip 报"成功",全局目录里却什么都没有。
> 实测:9 个依赖里 8 个因版本与用户态不同而被 pip 升级(侥幸落到全局),
> 唯独 `cfgv` 用户态已是同版本 **3.5.0** → 被静默跳过 →
> `-s` 视角下 `ModuleNotFoundError: No module named 'cfgv'`。
**诊断命令**(`-s` = 禁用 user-site,模拟 SYSTEM 视角):
```powershell
# 看全局目录到底有没有(而不是 pip 怎么说)
& 'C:\ProgramData\anaconda3\python.exe' -s -m pre_commit --version
# 逐个依赖点名,找出被静默跳过的那个
& 'C:\ProgramData\anaconda3\python.exe' -s -c "import cfgv, identify, nodeenv, virtualenv, distlib, filelock, platformdirs, yaml; print('all ok')"
# 反查 pip 为何跳过(会显示 "Requirement already satisfied ... roaming ...")
& 'C:\ProgramData\anaconda3\python.exe' -m pip install --no-user --dry-run cfgv
```
**修复(必须提权)**:脚本 `install_precommit_global.ps1` +
`install_precommit_global2.ps1`(逐包安装 + 逐个 import 复验);
对**被静默跳过**的包补一支 `fix_cfgv_global.ps1`,核心是强制重装:
```powershell
& $Py -m pip install --no-user --force-reinstall --no-deps `
--disable-pip-version-check --no-warn-script-location cfgv
```
> `--force-reinstall` 无视"已满足"判定,`--no-deps` 避免牵连其它包。
> 三个脚本都在 `D:\Docker\act_runner\`。
**验证(四条全过才算好)**:
```powershell
$Py = 'C:\ProgramData\anaconda3\python.exe'
& $Py -s -m pre_commit --version # pre-commit 4.6.2
Test-Path 'C:\ProgramData\anaconda3\Lib\site-packages\cfgv.py' # True
# 关键:确认文件真在【全局】目录,而不是 roaming
Get-ChildItem 'C:\ProgramData\anaconda3\Lib\site-packages' -Filter 'cfgv*'
```
> ⚠️ 别再往 `.venv` 里装就以为 CI 能用了——CI 虽然会检查 `.venv\Scripts\python.exe`,
> 但 `actions/checkout@v4` 拉的是**干净克隆**,`.venv` 是 gitignore 的,**根本不存在**。
### 5.1.2 ⚠️ 关键坑:`doc-links` 钩子「本地过、CI 挂」= 引用了 gitignore 的运行时文件
**现象**:`文档链接存在性 (D-3/D-7)` 钩子在**本地** `pre-commit run --all-files` 通过,
一上 CI 就 `Failed`(exit 1),报的行/路径你在本地明明存在。
**根因**:CI 的 `actions/checkout@v4` 拉的是**干净克隆**,而钩子校验的是
**仓库根相对路径是否存在**(`check_backtick_paths`,见 `scripts/check_doc_links.py`)。
若文档引用了 **gitignore 的运行时产物**——
如 `.dsh/skills/serial-assistant/scripts/state/meta.json`(运行时未创建,`.gitignore:132` 排除整个 `state/`)——
你本机因为跑过工具所以文件在,**克隆里则不存在** → 判为失效引用。
**修复**:按 `docs/文档规范.md` §3.3 给该行加**状态词**(脚本的 `STATUS_HINTS`:
`计划 / 未创建 / 尚未创建 / 已删除 / 删除 / 不存在 / 缺失 / 断链 / 待建 / 待创建`)。
```markdown
| `.dsh/skills/serial-assistant/scripts/state/meta.json`(运行时未创建) | daemon 元数据(pid / 版本 / gen) |
```
> ⚠️ **状态词必须与引用在【同一行】且 ≤40 字符**(`STATUS_PROXIMITY`)。
> 把说明写在**上一行**是无效的——脚本逐行调用 `has_status_hint(line, ...)`,
> 跨行的文字不在窗口内。
**本地复现 CI 条件的姿势**(否则永远测不出来):
```bash
# 把运行时目录临时移开,模拟干净克隆,跑完再移回
mv .dsh/skills/serial-assistant/scripts/state .dsh/skills/serial-assistant/scripts/state.bak_ci
& '.venv\Scripts\python.exe' scripts/check_doc_links.py --all # 或全局 python -m pre_commit run doc-links --all-files
mv .dsh/skills/serial-assistant/scripts/state.bak_ci .dsh/skills/serial-assistant/scripts/state
```
> 更一般的教训:**任何校验"文件是否存在/数字是否一致"的钩子,都要在
> "无本地产物"的干净克隆里验一次**,别只在开发机上跑。
### 5.1.3 机器级 `DTU_*` 环境变量(runner 的正式覆盖通道,2026-10-02 起)
workflow 已改为统一消费 `DTU_*` 接口(见 `本地工具路径环境变量方案.md` §3.3):
**显式变量优先 → 自动发现回退**,三个 job 步骤相关的变量:
| 变量 | 作用 | 未设置时的回退 |
|---|---|---|
| `DTU_PRECOMMIT_PYTHON` | 指向装有 pre-commit 的 python.exe(L0/L0.5 用) | PATH 的 `python` → 工作区 `.venv`(干净克隆里通常没有) |
| `DTU_CPPCHECK` | 指向 cppcheck.exe(L0 钩子用) | SYSTEM 的 PATH(用户级 PATH 不可见) |
| `DTU_MSVC_CL` | 指向 x64 `cl.exe`(L0.5 用) | `%ProgramFiles%`/`%ProgramFiles(x86)%` 推导的 VS 标准根自动探测 |
设置(**必须管理员**;scope 用 `Machine` 才能被 SYSTEM 任务看到):
```powershell
[Environment]::SetEnvironmentVariable('DTU_PRECOMMIT_PYTHON', 'C:\ProgramData\anaconda3\python.exe', 'Machine') # 示例:本机 runner
[Environment]::SetEnvironmentVariable('DTU_CPPCHECK', 'C:\Program Files\Cppcheck\cppcheck.exe', 'Machine')
[Environment]::SetEnvironmentVariable('DTU_MSVC_CL', 'C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Tools\MSVC\14.29.30133\bin\Hostx64\x64\cl.exe', 'Machine')
# ★ 设完必须重启 runner 任务(环境块是任务启动时快照的)
Stop-ScheduledTask -TaskName GiteaActionsRunner
Start-ScheduledTask -TaskName GiteaActionsRunner
```
> 与 §5.1.1 的关系:机器级 `DTU_PRECOMMIT_PYTHON` 是**正式通道**(指向哪个解释器由你说了算,
> 值无效时 job 直接报变量名、不猜);"把 pre-commit 装进全局 site-packages"仍是**兜底**
> (变量未设 + 全局 site 可见也能过)。两条路并存不冲突:变量设了就用变量,没设走探测。
> 显式变量值无效(文件不存在 / 无 pre-commit 模块)→ job **硬失败**并指明变量名——**不会**
> 静默回退到别的解释器,避免 runner 悄悄换了 Python 导致钩子行为漂移。
### 5.2 ⚠️ 关键坑:MSVC 的路径必须"标准"(或显式给变量)
CI 的 MSVC 探测(`l05` job)优先读机器级 `DTU_MSVC_CL`(§5.1.3);
未设置时按**标准布局自动探测**(根目录由 `%ProgramFiles` / `%ProgramFiles(x86)` 推导,
不再写死盘符):2019 → `Program Files (x86)`;2022 / 2026(实际目录名 `18`)→ `Program Files`,
各取 BuildTools/Community/Professional/Enterprise 子目录,找
`VC\Tools\MSVC\*\bin\Hostx64\x64\cl.exe`。本地 `unit_test_harness.py` 用同一套推导(已同步)。
**你本机实测命中第 1 条**:
`C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Tools\MSVC\14.29.30133\bin\Hostx64\x64\cl.exe` ✅
> ⚠️ 若将来 MSVC 装到 **D 盘或完全非标准路径** → 自动探测不到 → 两种处置(二选一):
> - 设机器级 `DTU_MSVC_CL` 指向 `cl.exe`(**推荐**,不动任何代码;值无效会硬报错、不静默跳过)
> - 或把 VS 装回标准根(探测逻辑按 `%ProgramFiles*` 推导,换盘符的标准布局仍命中)
### 5.3 验证运行器能找到工具链(注册前先跑一遍)
```powershell
& '.venv\Scripts\python.exe' scripts/ci_local.py
```
这条命令跑的就是 CI 的同一套判定。**本地能过,runner 上基本就能过**(前提是 PATH 一致)。
---
## 6. 端到端验证
按顺序做,任一步不过就停在那里查:
| # | 动作 | 期望 |
|---|---|---|
| 1 | `.\gitea-runner.exe daemon` 前台启动 | 打印 `win-stm32-ci` + `labels: [windows]` + `declare successfully` |
| 2 | 浏览器 Settings → Actions → Runners | 🟢 Idle |
| 3 | 确认 workflow 已推到远程(**见下方注意**) | 远程分支有 `.gitea/workflows/ci.yml` |
| 4 | Push 一次(或页面 **Run workflow**) | Actions 页出现运行中任务 |
| 5 | 看 `l0-gates` 日志 | `pre-commit ... Passed`;`mem-numbers` 显示 `Skipped`(无 Keil `.map`,正常) |
| 6 | 看 `l05-host-unit-tests` 日志 | `=== Task Watchdog Tests: 39/39 PASSED (failed=0) ===` |
| 7 | 提交记录旁 | ✅ 绿灯 |
> ⚠️ **第 3 步是当前的最大阻塞**:本仓库的 `.gitea/workflows/ci.yml` 还在本地
> (提交 `e904865` / `1e01950` / `52e6dcb` / `c305a8d` 均未 push)。**workflow 不上远程,CI 永远不会跑**,
> 哪怕 runner 配得再完美。
### 6.1 无网页登录时的状态核验(MySQL 直查)
runner 是否在线,可以直接查 Gitea 数据库确认(本机 MySQL 凭据见
`D:\Docker\Gitea\custom\conf\app.ini` 的 `[database]` 段):
```bash
MYSQL="/c/Program Files/MySQL/MySQL Server 8.4/bin/mysql.exe"
"$MYSQL" -h 127.0.0.1 -P 3306 -u wandering -p<pwd> -D gitea -e \
"SELECT id,name,version,last_online,agent_labels FROM action_runner;"
```
| 字段 | 判读 |
|---|---|
| `name` / `version` | 注册名与版本(应为 `win-stm32-ci` / `v4.0.1`) |
| `agent_labels` | 应为 `["windows"]` ← **不含 windows 就是标签配错了** |
| `last_online` | Unix 秒;**与当前时间相差 < 30 秒即在正常心跳** |
```bash
date +%s # 对比 last_online
```
相关表:`action_runner`(已注册 runner)、`action_runner_token`(注册令牌,
字段 `repo_id` 对应 `repository.id`)。
---
## 7. 排障对照表
| 现象 | 根因 | 处置 |
|---|---|---|
| runner 一直 **Idle**,push 后任务**排队不跑** | `--labels` 不是 `windows`(最常见) | 改 `config.yaml` 的 `runner.labels: ["windows:host"]` → 重启任务(§4.4) |
| `daemon` 启动报 **connection refused / dial tcp** | `--instance` 填了不可达地址 | 删 `.runner` → 重新 `register`,`--instance http://127.0.0.1:3001` |
| 启动报 **token** 相关错误 | Registration Token 过期(约 1 小时)或已用过 | 回网页重新创建 runner 拿新 token,重新 `register` |
| 任务跑到一半报 `python: 不是内部或外部命令` | SYSTEM 账户 PATH 没有 Python | 确认 Python 在**系统** PATH → 重启任务(§5.1) |
| `l0-gates` 报 `::error::runner 缺少:Python, cppcheck` | 同上,PATH 未生效 | 装齐 + 重启任务(§5.1) |
| `checkout` 步骤卡住 / 拉不到 action | Gitea 默认从 **github.com** 拉 action,本机不通外网 | 见 §8 |
| `l05` 日志 `::warning::runner 未安装 MSVC` | MSVC 不在标准路径 | 见 §5.2 |
| `l05` 报 `fatal error C1083: 无法打开 "stdio.h"` | 缺 Windows SDK | 装 VS 时勾"使用 C++ 的桌面开发" + SDK |
| 日志里 `pre-commit` 第一次特别慢 | 首次下载各钩子环境 | 正常,`cache.enabled: true` 后第二次快 |
| **改了 `.runner` / 换机器** | 注册信息失效 | 删 `.runner` → 重新 `register` |
| `daemon install` 报 `accepts at most 0 arg(s)` | **v4 已移除该子命令,且无任何服务子命令** | **改用任务计划程序**(§4) |
| 🔴 `Start-Service gitea-runner` 报 **无法启动服务**(错误 1053) | **`daemon` 不是服务型程序**,不向 SCM 回报 `SERVICE_RUNNING` | **删除该服务,改用任务计划程序**(§4.0 / §4.5) |
| `Register-ScheduledTask` 报 **拒绝访问** | Shell 未提权(`IsAdmin=False`) | 右键 PowerShell → **以管理员身份运行**(§4.6) |
| 任务 `LastTaskResult` 非 0 | 启动包装脚本路径/权限问题 | 看 `log\runner-stdout.log`;确认 `.cmd` 存在且为 ASCII 编码 |
| 🔴 `l0-gates` 报 `no Python with a SYSTEM-visible pre-commit` | `pre-commit` 只装在**用户态** site-packages(SYSTEM 不可见);或 pip 因 user-site 已满足而**静默跳过**全局安装 | 提权装到**全局**(§5.1.1),务必用 `-s` 复验 |
| `l0-gates` 报 `TerminatorExpectedAtEndOfString` / 缺少右引号 | `run:` 块含**中文**,被 WinPS 5.1 按 GBK 解码撞坏引号 | `run:` 块改**纯 ASCII**(中文只留 `name:`/注释) |
| `l0-gates` 报 `Cannot find: pwsh in PATH` | `shell: pwsh` = PS7,本机只有 WinPS 5.1 | 改 `shell: powershell` |
| `l0-gates` 报 `runner missing: cppcheck` | cppcheck 仅在用户级 PATH(SYSTEM 不可见) | 设机器级 `DTU_CPPCHECK`(§5.1.3,**workflow 不再回落固定目录**),或把 cppcheck 目录加入系统 PATH 后重启任务 |
| 🔴 `doc-links` 钩子本地过、CI 挂 | 引用了 **gitignore 的运行时文件**(如 `.dsh/skills/serial-assistant/scripts/state/*.json`),本地存在、干净克隆里没有 | 该行加状态词「未创建/不存在」等(§5.1.2) |
| `l05` 日志 `::warning::runner has no MSVC` 但仍 **PASS** | 缺 MSVC 时 L0.5 **降级跳过**(不硬失败) | 装 VS BuildTools(§5.2),否则该层无覆盖 |
| 任务跑满 72 小时被杀 | 计划任务默认 `ExecutionTimeLimit` | 设为 `0`(不限时长),见 §4.3 警示 |
| `config set log.file ...` 报 `unknown config key` | **v4 的 log 段无 `file` 键** | 用 `log.job.dir` 或输出重定向(§3) |
| **脚本报一堆 `源文本中包含无法识别的标记` / `字符串缺少终止符` / 中文显示成 `鐒跺悗鎵ц`** | 🔴 **`.ps1` 无 BOM,WinPS 5.1 按 GBK 解读 UTF-8** | **给脚本加 UTF-8 BOM**(§7.1) |
### 7.1 🔴 必读:给含中文的 `.ps1` 加 UTF-8 BOM
**这是本机实测踩过的坑**(2026-10-02)。Windows PowerShell 5.1 对**无 BOM** 的脚本文件
按**系统 ANSI 代码页**(简中 = **GBK / CP936**)解码:
- UTF-8 的中文字节被当 GBK 解 → 显示成 `鐒跺悗鎵ц` 这类乱码;
- 更糟的是,**乱码字符会撞坏字符串引号配对**(GBK 双字节的尾字节可能是 `"` 或 `\`)
→ 解析器报 `源文本中包含无法识别的标记` / `字符串缺少终止符: "` / `缺少右"}"`
—— 报错行号看起来毫无规律,**极易误判成逻辑问题**。
**修法**:给文件头加 3 字节 `EF BB BF`(UTF-8 BOM)。与仓库里带 BOM 的源文件
(`Drivers/BSP/UI/*`、`App/cfg_file.c`)是同一个道理。
```powershell
# 方法 A:PowerShell 重存为 UTF8BOM
$c = Get-Content "D:\Docker\act_runner\deploy_runner_task.ps1" -Raw -Encoding UTF8
[System.IO.File]::WriteAllText("D:\Docker\act_runner\deploy_runner_task.ps1", $c, (New-Object System.Text.UTF8Encoding($true)))
```
```bash
# 方法 B:直接前缀 BOM 字节(bash/Git Bash)
cd "/d/Docker/act_runner"
for f in deploy_runner_task.ps1 check_env.ps1; do
printf '\xef\xbb\xbf' > "$f.tmp" && cat "$f" >> "$f.tmp" && mv "$f.tmp" "$f"
done
```
**校验**:用 PowerShell 解析器(不执行)确认语法干净:
```powershell
foreach ($f in @("deploy_runner_task.ps1","check_env.ps1")) {
$errs = $null
$null = [System.Management.Automation.Language.Parser]::ParseFile(
"D:\Docker\act_runner\$f", [ref]$null, [ref]$errs)
if ($errs.Count) { "$f : FAIL -> $($errs[0].Message)" } else { "$f : OK" }
}
```
> 💡 替代方案:改用 **PowerShell 7+(`pwsh`)**,它默认按 UTF-8 读脚本、无需 BOM。
> 但本机只有 WinPS 5.1,故加 BOM 最省事。
>
> 💡 **纯 ASCII 的脚本不受影响**。本目录的 `.ps1` 均含中文注释,现已统一加 BOM。
| 日志 | 位置 |
|---|---|
| runner **自身** stdout/stderr | `D:\Docker\act_runner\log\runner-stdout.log` |
| **每个 job** 的详细日志 | `D:\Docker\act_runner\log\job\` |
**看日志**:v4 的 `log` 段**没有 `file` 键**,两种落盘方式:
```yaml
# 方式 A:按 job 落文件(推荐,防上报失败丢日志)
log:
level: info
job:
dir: "D:\\Docker\\act_runner\\log\\job"
```
```powershell
# 方式 B:前台运行 / 包装脚本里重定向
.\gitea-runner.exe daemon > "D:\Docker\act_runner\log\runner.log" 2>&1
```
**计划任务模式下看日志(两种)**:
| 日志 | 位置 |
|---|---|
| runner **自身** stdout/stderr | `D:\Docker\act_runner\log\runner-stdout.log`(由 `run_runner.cmd` 重定向) |
| **每个 job** 的详细日志 | `D:\Docker\act_runner\log\job\` |
任务本身的历史记录:**任务计划程序 → 任务计划程序库 → `GiteaActionsRunner` → 历史记录**;
或用 `Get-ScheduledTaskInfo -TaskName GiteaActionsRunner` 看 `LastTaskResult`。
---
## 8. ⚠️ 内网 action 拉取问题(你的环境很可能命中)
workflow 里用了 `actions/checkout@v4`。Gitea **默认从 `https://github.com` 拉取这个 action**
(不是从你自己的实例,也不是从 gitea.com —— 这点很反直觉,见
`CI配置指南.md` §1.2.1)。
**你的环境判断**:Gitea 装了 `OFFLINE_MODE = true`,说明这台机器的外网访问**可能受限**。
如果 runner 拉不到 github.com,checkout 步骤会卡住。
### 处置:改用零依赖写法(内网首选)
把 `.gitea/workflows/ci.yml` 里的 `actions/checkout@v4` 两步替换为纯 git 命令:
```yaml
- name: Checkout
shell: pwsh
run: |
git init .
git remote add origin $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git
git fetch --depth 1 origin $env:GITHUB_SHA
git checkout $env:GITHUB_SHA
```
**优点**:不依赖任何外部 action、不需要配 `DEFAULT_ACTIONS_URL`、不需要镜像 action。
**代价**:失去 checkout 的缓存优化(对本仓库影响很小)。
> 另两种方案(`DEFAULT_ACTIONS_URL = self` + 镜像 action 进实例 / 指向可达镜像站)见
> `docs/环境与部署/CI配置指南.md` §4.3。**注意 `self` 是关键字,不是写你的域名。**
**建议**:**先按原样试一次**。若 checkout 步骤报网络错误,再换成上面的零依赖写法。
---
## 9. 一键检查脚本(可选)
存为 `D:\Docker\act_runner\check_env.ps1`,注册前跑一遍:
```powershell
Write-Host "=== Gitea 可达性 ===" -ForegroundColor Cyan
try {
$v = (Invoke-RestMethod -Uri "http://127.0.0.1:3001/api/v1/version" -TimeoutSec 8).version
Write-Host " OK Gitea $v (127.0.0.1:3001)" -ForegroundColor Green
} catch { Write-Host " FAIL 连不上 Gitea: $_" -ForegroundColor Red }
Write-Host "=== 工具链 ===" -ForegroundColor Cyan
foreach ($c in @("python", "cppcheck", "git")) {
$p = Get-Command $c -ErrorAction SilentlyContinue
if ($p) { Write-Host " OK $c -> $($p.Source)" -ForegroundColor Green }
else { Write-Host " FAIL 缺 $c" -ForegroundColor Red }
}
$pc = (python -m pre_commit --version 2>&1)
if ($LASTEXITCODE -eq 0) { Write-Host " OK $pc" -ForegroundColor Green }
else { Write-Host " FAIL 缺 pre-commit" -ForegroundColor Red }
Write-Host "=== DTU_* 机器级变量(§5.1.3)===" -ForegroundColor Cyan
foreach ($n in @("DTU_PRECOMMIT_PYTHON", "DTU_CPPCHECK", "DTU_MSVC_CL")) {
$v = [Environment]::GetEnvironmentVariable($n, "Machine")
if ($v) { Write-Host " OK $n = $v" -ForegroundColor Green }
else { Write-Host " -- $n(未设置:workflow 将走自动发现)" -ForegroundColor Yellow }
}
Write-Host "=== MSVC cl.exe(DTU_MSVC_CL 优先,否则标准布局推导)===" -ForegroundColor Cyan
if ($env:DTU_MSVC_CL -and (Test-Path $env:DTU_MSVC_CL)) {
Write-Host " OK $($env:DTU_MSVC_CL)(来自 DTU_MSVC_CL)" -ForegroundColor Green
} else {
$pf = ${env:ProgramFiles}
$pf86 = ${env:ProgramFiles(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\18\BuildTools'),
(Join-Path $pf 'Microsoft Visual Studio\18\Community')
)
$hit = $null
foreach ($r in $roots) {
if (Test-Path $r) {
$hit = Get-ChildItem -Path $r -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like '*Hostx64\x64*' } | Select-Object -First 1
if ($hit) { break }
}
}
if ($hit) { Write-Host " OK $($hit.FullName)" -ForegroundColor Green }
else { Write-Host " FAIL 未找到 MSVC (Hostx64\x64\cl.exe);可设机器级 DTU_MSVC_CL" -ForegroundColor Red }
}
```
---
## 10. 最小操作清单(TL;DR)
```powershell
# 1) 下载(v4.0.1;注意仓库名是 gitea/runner,二进制名是 gitea-runner)
New-Item -ItemType Directory -Force -Path "D:\Docker\act_runner" | Out-Null
cd "D:\Docker\act_runner"
Invoke-WebRequest -Uri "https://gitea.com/gitea/runner/releases/download/v4.0.1/gitea-runner-4.0.1-windows-amd64.exe" -OutFile "gitea-runner.exe"
# 2) 注册(token 从网页 Settings→Actions→Runners 拿;instance 用回环)
.\gitea-runner.exe register --no-interactive `
--instance "http://127.0.0.1:3001" `
--token "<粘贴 token>" `
--name "win-stm32-ci" `
--labels "windows:host"
# 3) 前台试跑,确认能连上
.\gitea-runner.exe daemon # 看到 "labels: [windows] ... declare successfully" 即成功,Ctrl+C 停
# 4) 常驻 —— ⚠️ daemon 不是服务型程序,必须用【任务计划程序】(管理员 PowerShell)
powershell -ExecutionPolicy Bypass -File "D:\Docker\act_runner\deploy_runner_task.ps1"
# 等价手动:
# Register-ScheduledTask -TaskName GiteaActionsRunner -Action (
# New-ScheduledTaskAction -Execute "D:\Docker\act_runner\run_runner.cmd") `
# -Trigger (New-ScheduledTaskTrigger -AtStartup) `
# -Principal (New-ScheduledTaskPrincipal -UserId SYSTEM -LogonType ServiceAccount -RunLevel Highest)
# Start-ScheduledTask -TaskName GiteaActionsRunner
# 5) ⚠️⚠️ 把 pre-commit 装到【全局】site-packages(否则 L0 必挂,见 §5.1.1)
# —— 这是本环境最容易漏、也最难自己看出来的一步(用户态对 SYSTEM 不可见)
powershell -ExecutionPolicy Bypass -File "D:\Docker\act_runner\install_precommit_global.ps1"
powershell -ExecutionPolicy Bypass -File "D:\Docker\act_runner\install_precommit_global2.ps1"
# 若有包报"安装成功"但 import 失败(pip 静默跳过):
powershell -ExecutionPolicy Bypass -File "D:\Docker\act_runner\fix_cfgv_global.ps1"
# 5b) 2026-10-02 起推荐:改设机器级 DTU_* 变量(§5.1.3,显式覆盖、报错直指变量名);
# 全局 site-packages 安装保留为未设变量时的兜底。设完同样要重启 runner 任务。
# (管理员 PowerShell;值按本机实际路径改)
# [Environment]::SetEnvironmentVariable('DTU_PRECOMMIT_PYTHON', 'C:\ProgramData\anaconda3\python.exe', 'Machine')
# [Environment]::SetEnvironmentVariable('DTU_CPPCHECK', 'C:\Program Files\Cppcheck\cppcheck.exe', 'Machine')
# [Environment]::SetEnvironmentVariable('DTU_MSVC_CL', '<本机 x64 cl.exe 完整路径>', 'Machine')
# 6) 复验(-s = 模拟 SYSTEM 视角,必须过)
& 'C:\ProgramData\anaconda3\python.exe' -s -m pre_commit --version # pre-commit 4.6.2
# 7) 回网页确认 runner 为 Idle → 触发 CI
# push 触发(注意:纯 .md 改动在 workflow 的 paths-ignore 里,不会触发);
# 手动触发:
# curl -u "<user>:<pass>" -X POST -H "Content-Type: application/json" `
# -d '{"ref":"v2-on-ui"}' `
# "https://gitea.bitnasdaq.vip/api/v1/repos/wandering1/STM32F4-Base/actions/workflows/ci.yml/dispatches"
```
> **CI 验收标准**(2026-10-02 已达成,run#8):`action_run_job` 里两个 job 均 `status=1`——
> `l0-gates`(14 个 pre-commit 钩子全 Passed,`内存/Flash` 在无 `.map` 时 Skipped)
> + `l05-host-unit-tests`(MSVC 探测 + 39/39 单测)。
**配套脚本**(均在 `D:\Docker\act_runner\`,**均已加 UTF-8 BOM**,可直接在 WinPS 5.1 运行):
| 脚本 | 用途 | 需要管理员 |
|---|---|---|
| `check_env.ps1` | 环境自检(Gitea 可达 / 工具链 / MSVC / 任务状态) | 否 |
| `deploy_runner_task.ps1` | **一键部署**(自检 + 清旧服务 + 建计划任务 + 启动 + 验证) | **是** |
> 早前基于 Windows 服务的 `deploy_runner.ps1` / `install_service.ps1` 已删除
> —— 那条路走不通(§4.0)。
---
## 变更记录
| 日期 | 变更 |
|---|---|
| 2026-10-02 | **workflow 接入 `DTU_*` 机器级变量**(`本地工具路径环境变量方案.md` 实施):`l0-gates` 的 Python 探测改为 `DTU_PRECOMMIT_PYTHON`(显式,无效即硬失败)→ PATH → `.venv`(删除 `C:\ProgramData\anaconda3` 固定候选);cppcheck 改为 `DTU_CPPCHECK` 优先 → PATH(**移除 `C:\Program Files\Cppcheck` 固定回退**);`l05` 的 MSVC 探测改为 `DTU_MSVC_CL` 优先 → `%ProgramFiles*` 推导的标准根(与 `unit_test_harness.py` 同步,不再写死盘符);新增 §5.1.3 机器级变量设置方法(`Machine` scope + 重启任务)、§5.2 与 check_env 样例、排障表 cppcheck 行、TL;DR 5b 步骤。§5.1.1 的报错原文标注为历史记录 |
| 2026-10-02 | ✅ **首次 CI 全绿(run#8:L0 + L0.5 双 job Success)**。定位并修复最后两个拦路虎:① **§5.1.1 用户态 site-packages 陷阱**——`pre-commit` 只装在 `%APPDATA%\Roaming\Python\...`(SYSTEM 不可见);且提权 `pip install --no-user` 会因 pip 扫**整个 `sys.path`**、见 user-site 已满足而**静默跳过**全局安装(9 依赖中唯 `cfgv` 因版本相同被跳过→`-s` 下 `ModuleNotFoundError`)。修法:提权 + `--force-reinstall --no-deps` 强制落全局,并以 `-s` 复验。② **§5.1.2 doc-links 假阳性**——文档引用了 gitignore 的运行时文件 `.dsh/skills/serial-assistant/scripts/state/meta.json`,本地存在、干净克隆无 → 按《文档规范》§3.3 加同状态词「运行时未创建」。排障表补 6 条(pre-commit 不可见 / GBK 中文 / pwsh / cppcheck / doc-links / MSVC 降级)。新增配套脚本 `install_precommit_global.ps1`、`install_precommit_global2.ps1`、`fix_cfgv_global.ps1` |
| 2026-10-02 | 🔴 **常驻方式重大修正(实测推翻)**:`Start-Service gitea-runner` 必然失败。经二进制逆向确认 `gitea-runner v4.0.1` **无任何服务管理代码**(命令集仅 `bug-report/cache-server/config/daemon/exec/register`,无 `service.go`;`OpenSCManagerW` 等符号来自 Docker SDK,非服务安装代码),`daemon` 是纯控制台程序、不向 SCM 回报 `SERVICE_RUNNING`。**§4 全节重写为「任务计划程序」方案**(新增 §4.0 根因分析、§4.2 `deploy_runner_task.ps1`、§4.3 手动命令 + 72h 超时警示、§4.5 清理遗留服务、§4.6 提权);删除失效的 `deploy_runner.ps1`/`install_service.ps1`;排障表补「错误 1053」「LastTaskResult 非 0」「72 小时被杀」3 条;拓扑图/§5.1/§7 日志/§10 TL;DR 同步更新。另修 `config.yaml` 的 `fetch_timeout` 5m→1m(超出 RPC 1m 会被截断并告警) |
| 2026-10-02 | **修 `.ps1` 编码坑(实测)**:无 BOM 的 UTF-8 脚本被 WinPS 5.1 按 GBK 解读 → 中文乱码(`鐒跺悗鎵ц`)并撞坏引号配对,报 `源文本中包含无法识别的标记`/`字符串缺少终止符`。**新增 §7.1「给含中文的 `.ps1` 加 UTF-8 BOM」**(两种修法 + 解析器校验命令);排障表补该条目;配套脚本已统一加 BOM |
| 2026-10-02 | **按实测修正为 v4.0.1**:act_runner 已更名 **`gitea-runner`**,发布页迁至 `gitea.com/gitea/runner`。**v4 移除 `daemon install/start/stop/status`**;**v4 `log` 段无 `file` 键**(§3 改用 `log.job.dir`);补 §1.3 命令集对照、§6.1 MySQL 直查核验、排障表(`accepts at most 0 arg(s)` / `unknown config key log.file` 等) |
| 2026-10-02 | 初版:针对**本机 Gitea 原生部署**(`D:\Docker\Gitea\gitea.exe` + Windows 服务 + `127.0.0.1:3001`)定制 Runner 端部署全流程。核心要点:`--instance` 用回环地址、`--labels "windows:host"` 必须精确匹配 `runs-on`、PATH 运行账户坑、MSVC 路径约束、内网 action 拉取的零依赖改法 |
@@ -81,7 +81,7 @@ $env:DTU_STLINK_CLI = 'D:\Tools\ST-LINK Utility\ST-LINK_CLI.exe'
- 新电脑只配置本机 `DTU_*` 环境变量(§3),clone 仓库后可直接运行各开发命令,**不修改任何脚本/工作流中的本机路径**。
- 显式变量指向无效路径时,命令以非零退出码失败且错误信息指明变量名,不静默使用其他工具。
- 仓库内不出现提交的本机绝对工具路径(文档中的示例路径除外)。
- 跨机验证操作清单见 `项目部署环境要求.md` §2.12;runner 侧机器级变量设置见 `Gitea_Runner部署指南.md` §5.1.3。
- 跨机验证操作清单见 `项目部署环境要求.md` §2.12;runner 侧机器级变量设置见 `docs/CI/远程CI完整指南.md` §4.1.3。
---
@@ -84,7 +84,7 @@ Graphviz / Cppcheck 的安装位置差异一律用 `DTU_*` 环境变量表达(
> 统一解析语义:变量**显式设置但无效 → 立即报错**(指出变量名与收到的路径),不静默回退到别的安装;
> 未设置 → 按工具走 PATH / 标准布局自动发现(可选工具允许降级,如 Graphviz 缺失只跳渲染)。
> CI runner 以 **SYSTEM 服务**运行,用户级变量对其不可见——runner 用**机器级**变量注入(见
> `Gitea_Runner部署指南.md` 环境变量节)。
> `docs/CI/远程CI完整指南.md` §4.1.3)。
### 2.2 MCU 构建工具链(必需)
@@ -415,7 +415,7 @@ workflow 见 `.gitea/workflows/ci.yml`;覆盖边界与判定细节见 [`../测
| Gitea 版本 | ≥ 1.21(实测远程 **1.25.4**) | workflow 置于 `.gitea/workflows/`,兼容 GitHub Actions 语法 |
| Actions 开关 | 实例 `[actions] ENABLED = true` | site admin 配置;未开启则 push 不触发任何 job |
| runner OS | **Windows**(`runs-on: windows`) | labels 须含 `windows`;与开发机同平台,避免脚本语法分叉 |
| runner 软件 | Python 3.x、cppcheck、pre-commit、**MSVC**(VS C++ 工具链 + Windows SDK) | 前三个缺 → `l0-gates` **硬失败**(`::error::`);MSVC 缺 → `l05-host-unit-tests` **降级跳过并告警**(不硬失败,但等于 L0.5 裸奔)。runner 以 SYSTEM 服务运行,**用户级 PATH/变量不可见**:自动发现失败时用**机器级** `DTU_PRECOMMIT_PYTHON` / `DTU_CPPCHECK` / `DTU_MSVC_CL` 注入(见 `Gitea_Runner部署指南.md` 环境变量节,§2.1.1) |
| runner 软件 | Python 3.x、cppcheck、pre-commit、**MSVC**(VS C++ 工具链 + Windows SDK) | 前三个缺 → `l0-gates` **硬失败**(`::error::`);MSVC 缺 → `l05-host-unit-tests` **降级跳过并告警**(不硬失败,但等于 L0.5 裸奔)。runner 以 SYSTEM 服务运行,**用户级 PATH/变量不可见**:自动发现失败时用**机器级** `DTU_PRECOMMIT_PYTHON` / `DTU_CPPCHECK` / `DTU_MSVC_CL` 注入(见 `docs/CI/远程CI完整指南.md` §4.1.3) |
| Keil | **不需要** | 刻意不跑 `@build`(需授权);`.map` 缺失时 `mem-numbers` 钩子自动跳过 |
| `mem-numbers` | 有 `.map` 就跑,无则 `SKIP` | Keil 构建产物 `.map` 已 gitignore;自建带 Keil 的 runner 会照常跑该钩子 |
@@ -635,7 +635,7 @@ $env:DTU_KEIL_UV4 = 'D:\NotExist\UV4.exe' # 仅当前会话,不动用户级
| LVGL 模拟器部署 | `docs/UI设计/LVGL模拟器部署与使用说明.md`、`simulator/README.md` |
| 串口调试 / 自动化测试 | `docs/测试说明/串口助手使用指南.md`、`docs/测试说明/串口助手设计说明.md`、`docs/测试说明/自动化测试流程.md` |
| **测试分层 / L0.5 主机侧单测** | `docs/测试说明/测试开发标准流程.md`(分层与门禁)· `docs/测试说明/看门狗单元测试说明.md`(L0.5 范式:MSVC 离线编译 + 平台桩) |
| **CI(Gitea Actions)** | `docs/环境与部署/Gitea_Runner部署指南.md`(**Runner 端部署全流程:下载 / 注册 / 常驻 / 排障**,针对本机原生 Gitea 定制)· `docs/环境与部署/CI配置指南.md`(服务端 + workflow 全流程)· `docs/测试说明/测试开发标准流程.md` §9(覆盖边界) |
| **CI(Gitea Actions)** | `docs/CI/README.md`(**远程 CI 唯一入口**:服务端 / Runner 部署与保活 / workflow / 排障,**仅本机一台 runner,日常开发不必读**)· `docs/测试说明/测试开发标准流程.md` §9(覆盖边界) |
| pre-commit / cppcheck | `docs/代码规范/pre-commit使用说明.md`、`docs/代码规范/cppcheck使用说明.md` |
| 引用关系图(文件引用) | `docs/架构设计/引用关系图说明.md`(生成工具 `tools/refgraph`;渲染依赖 Graphviz,§2.8) |
| 各模块测试指南 | `docs/测试说明/CH395F_Test_Guide.md`、`GD5F2GQ5UE_Test_Guide.md`、`SD2506_Test_Guide.md`、`FTP_Test_Guide.md` |
@@ -647,6 +647,7 @@ $env:DTU_KEIL_UV4 = 'D:\NotExist\UV4.exe' # 仅当前会话,不动用户级
| 日期 | 变更 |
|---|---|
| 2026-10-12 | **远程 CI 文档迁出本目录**:`CI配置指南.md` 与 `Gitea_Runner部署指南.md` 合并为 **`docs/CI/远程CI完整指南.md`**(入口 `docs/CI/README.md`),本文三处引用改指新路径与新章节号(§4.1.3 机器级变量、§7 相关文档登记)。原因:远程 CI 只涉及本机一台 runner,不应与"任何机器都要过"的部署核对清单混在一起 |
| 2026-10-10 | **删除旧档 NCFG 迁移**(升级/迁移口径变更):旧固件遗留的 `/cfg/netcfg.bin` 不再"首启自动迁移"——旧档若存在则被忽略(不读、不删、不迁移),配置直接回落工厂默认(`/config.ini` `[network]` 段模板)。`net_cfg_legacy.c/h` 与 `cfg_store_legacy_path()` 已移除;携带旧档的设备升级后需经 UI 或 FTP 重新配置网口。§五 现场部署 IP 行同步 |
| 2026-10-02 | **模拟器构建修复**(配置引擎重构遗留):`simulator/CMakeLists.txt` 的配置文件引擎引用同步到迁移后位置——包含目录 `App/`→`App/cfg/`(`cfg_file.h`/`cfg_file_store.h`)、源文件 `App/cfg_file.c`→`App/cfg/cfg_file.c`、包含目录 `test/`→`test/board/`(`test_config.h`/`net_latency_test.h`);全量 include 离线核查无其他断链,模拟器全量构建恢复(`--cfgtest` 四场景 PASS,rc=0)。§2.12 验证链 ⑤ 行同步更新 |
| 2026-10-02 | 更正 Device Pack 必装范围:`Keil::CMSIS` pack 不再列为必装项——本工程不依赖它(核心头文件由仓库 `Drivers/CMSIS/` 提供,`.uvprojx` 只引用 DFP pack;2026-10-02 本机实测未装 CMSIS pack 构建 0/0 通过),且其 keil.com 直链(5.7.0~6.1.0)均已下架 404;必装的只有 `Keil::STM32F4xx_DFP`(3.1.1,与 `.uvprojx` PackID 一致)。§2.2 / 第 2 步 / 搭建清单 / §2.10 同步更正 |
+7 -1
View File
@@ -27,7 +27,13 @@
| 脚本 / 文件 | 作用 |
|---|---|
| `ci_local.py` | **本地跑与 CI 等价的门禁**(L0 pre-commit + L0.5 主机侧单测):自动选用装了 pre-commit 的解释器;无 `.map` 时自适应跳过 `mem-numbers`;退出码 0/1/2 与 CI 对齐。CI 定义见 `../.gitea/workflows/ci.yml`,说明见 `docs/测试说明/测试开发标准流程.md` §9 |
| `ci_local.py` | **本地跑与 CI 等价的门禁**(L0 pre-commit + L0b 提交信息 + L0.5 主机侧单测):自动选用装了 pre-commit 的解释器;无 `.map` 时自适应跳过 `mem-numbers`;退出码 0/1/2 与 CI 对齐。CI 定义见 `../.gitea/workflows/ci.yml`,覆盖边界见 `docs/测试说明/测试开发标准流程.md` §9 |
| `ci_gitlint_range.ps1` | CI 侧**提交信息门禁**:逐条校验本次 push/PR 引入的提交(`--all-files` 跑不到 `commit-msg` stage,故必须单独守) |
| `ci_find_python.ps1` | CI 侧 Python 探测(候选顺序须与 `ci_local.py` 同序同步,否则 CI 与本地判定会分叉) |
| `ci_runner_bootstrap.ps1` | **本机 runner 运维脚本**(体检 `-Status` / 干跑 `-DryRun` / 部署 `-SetEnv -StartNow`):装"等 Gitea 就绪 + 崩溃自拉起"的看门狗 + 开机计划任务 + 机器级 `DTU_*` |
> ⚠️ 后三个脚本是**本机 runner 专属运维**(远程 CI 只跑在本机一台机器上),用法、原理与
> 踩坑全部集中在 `docs/CI/README.md`,本表只登记不展开。
## 使用方式
+179
View File
@@ -0,0 +1,179 @@
<#
.SYNOPSIS
Pick a Python interpreter that actually works, for the CI jobs (and for
local use). Prints diagnostics with Write-Host and emits ONLY the chosen
interpreter path on the success stream, so a caller can capture it:
$py = & './scripts/ci_find_python.ps1' -NeedPreCommit -WriteEnv
.DESCRIPTION
Why this file exists (2026-10-11 CI incident):
The DTU_* env-var refactor moved the interpreter probe inline into
.gitea/workflows/ci.yml and narrowed it to two candidates
(PATH python + repo .venv). On the Windows runner BOTH are dead ends:
* PATH python for the SYSTEM account is C:\msys64\ucrt64\bin\python.exe,
which has no pre-commit module;
* the repo .venv is gitignored, so it does not exist in a fresh runner
workspace at all.
The interpreter that does work (a machine-wide install such as
C:\ProgramData\anaconda3\python.exe) was no longer probed, and no
machine-level DTU_PRECOMMIT_PYTHON had ever been set -> every L0 job died
in "environment check" before running a single hook.
So discovery lives here now: one implementation, shared by both CI jobs and
runnable offline, and it keeps the well-known machine-wide install
locations as fallbacks.
Probe order (see the DTU_* local-tool-path plan under docs/ -- this file is
kept pure ASCII on purpose: PS 5.1 decodes BOM-less files as CP936):
1. DTU_PRECOMMIT_PYTHON -- explicit override. If it is set but the file
is missing, or it cannot satisfy the requirement, this script FAILS
with the variable name and does NOT silently fall back.
2. python on PATH
3. <repo>\.venv\Scripts\python.exe
4. well-known machine-wide installs (Program Files / ProgramData /
%LOCALAPPDATA%\Programs\Python), newest version first
.PARAMETER NeedPreCommit
Require the interpreter to expose the pre-commit module (L0 needs it).
Probed with `-s` (no user site-packages) on purpose: the runner service
runs as SYSTEM, which cannot see a USER-scope site install at all, so only
an interpreter that works without user-site is accepted.
.PARAMETER WriteEnv
Append PYTHON=<path> to $env:GITHUB_ENV so later steps can use $env:PYTHON.
Written as ASCII: Out-File's PS 5.1 default is UTF-16LE, which appends
foreign-encoded lines to a file other steps also append to.
.OUTPUTS
The chosen python.exe path (single line, success stream).
.NOTES
Keep this file pure ASCII -- Windows PowerShell 5.1 decodes BOM-less files
as CP936, and non-ASCII bytes break string quoting (same trap as ci.yml).
Exit codes: 0 = found, 2 = no usable interpreter / bad DTU_* override.
#>
param(
[switch]$NeedPreCommit,
[switch]$WriteEnv
)
# Native probes write to stderr; the runner injects $ErrorActionPreference
# ='Stop', which would turn that into a terminating NativeCommandError and
# abort the probe loop before the next candidate is tried.
$ErrorActionPreference = 'Continue'
function Test-Interpreter {
param([string]$Exe, [bool]$RequirePreCommit)
if ([string]::IsNullOrWhiteSpace($Exe)) { return $false }
if (-not (Test-Path -LiteralPath $Exe -PathType Leaf)) { return $false }
$saved = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
if ($RequirePreCommit) {
& $Exe -s -m pre_commit --version 2>&1 | Out-Null
}
else {
& $Exe --version 2>&1 | Out-Null
}
return ($LASTEXITCODE -eq 0)
}
catch {
return $false
}
finally {
$ErrorActionPreference = $saved
}
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$require = [bool]$NeedPreCommit
$what = if ($require) { 'pre-commit' } else { 'a runnable interpreter' }
# --- 1. explicit override: honour it or fail loudly -------------------------
$explicit = $env:DTU_PRECOMMIT_PYTHON
if (-not [string]::IsNullOrWhiteSpace($explicit)) {
$explicit = $explicit.Trim()
if (-not (Test-Path -LiteralPath $explicit -PathType Leaf)) {
Write-Host "::error::DTU_PRECOMMIT_PYTHON is set but does not exist: $explicit"
Write-Host '::error::fix the variable (full path to a python.exe) or unset it to use auto-discovery'
exit 2
}
if (-not (Test-Interpreter -Exe $explicit -RequirePreCommit $require)) {
Write-Host "::error::DTU_PRECOMMIT_PYTHON does not provide $what (probed without user-site, i.e. SYSTEM-visible): $explicit"
Write-Host '::error::fix the variable, or unset it to use auto-discovery'
exit 2
}
Write-Host "using python (DTU_PRECOMMIT_PYTHON): $explicit"
if ($WriteEnv -and $env:GITHUB_ENV) {
"PYTHON=$explicit" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding ascii
}
Write-Output $explicit
exit 0
}
# --- 2..4 auto-discovery ----------------------------------------------------
$programFiles = $env:ProgramFiles
$programFilesX86 = ${env:ProgramFiles(x86)}
if ([string]::IsNullOrWhiteSpace($programFiles)) { $programFiles = 'C:\Program Files' }
if ([string]::IsNullOrWhiteSpace($programFilesX86)) { $programFilesX86 = 'C:\Program Files (x86)' }
$candidates = @()
$onPath = Get-Command python -ErrorAction SilentlyContinue
if ($onPath) { $candidates += $onPath.Source }
$candidates += (Join-Path $repoRoot '.venv\Scripts\python.exe')
# Newest first: a fresh install is the usual intent.
foreach ($ver in @('Python313', 'Python312', 'Python311', 'Python310')) {
$candidates += (Join-Path $programFiles "$ver\python.exe")
$candidates += (Join-Path $programFilesX86 "$ver\python.exe")
if ($env:LOCALAPPDATA) {
$candidates += (Join-Path $env:LOCALAPPDATA "Programs\Python\$ver\python.exe")
}
}
# Machine-wide distributions (these are what the runner actually had).
if ($env:ProgramData) {
foreach ($dist in @('anaconda3', 'miniconda3')) {
$candidates += (Join-Path $env:ProgramData "$dist\python.exe")
}
}
$candidates += 'C:\ProgramData\anaconda3\python.exe'
$candidates += 'C:\ProgramData\miniconda3\python.exe'
$tried = New-Object System.Collections.Generic.List[string]
$chosen = $null
foreach ($c in $candidates) {
if ([string]::IsNullOrWhiteSpace($c)) { continue }
if ($tried.Contains($c)) { continue }
$tried.Add($c) | Out-Null
if (Test-Interpreter -Exe $c -RequirePreCommit $require) { $chosen = $c; break }
}
if (-not $chosen) {
Write-Host "::error::no Python providing $what found (tried `$DTU_PRECOMMIT_PYTHON`, PATH python, repo .venv, and the well-known machine-wide installs)"
foreach ($t in $tried) { Write-Host " tried: $t" }
Write-Host '::error::note: the runner service runs as SYSTEM, so a USER-scope `pip install --user pre-commit` is invisible to it'
Write-Host '::error::fix: set a MACHINE-level DTU_PRECOMMIT_PYTHON (see scripts/ci_runner_bootstrap.ps1 -SetEnv), or install pre-commit into a machine-wide interpreter'
exit 2
}
Write-Host "using python (auto-discovered): $chosen"
# Out-Host, NOT the pipeline: a native command's stdout joins the success
# stream, which would pollute the single-line path the caller captures.
& $chosen --version 2>&1 | Out-Host
if ($require) { & $chosen -s -m pre_commit --version 2>&1 | Out-Host }
if ($WriteEnv -and $env:GITHUB_ENV) {
"PYTHON=$chosen" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding ascii
}
Write-Output $chosen
exit 0
+167
View File
@@ -0,0 +1,167 @@
<#
.SYNOPSIS
Commit-message gate for CI: run the `gitlint` pre-commit hook over every
commit in a pushed / pull-request range.
.DESCRIPTION
Why this file exists (2026-10-11 CI audit):
.pre-commit-config.yaml defines 17 hooks, but CI reported 16
(passed=15 failed=0 skipped=1). The missing one was `gitlint`: its hook
definition declares `stages: [commit-msg]`, while `pre-commit run
--all-files` only runs the `pre-commit` stage. So the commit-message rule
from AGENTS.md (single-line Chinese title < 100 chars) was enforced on
developer machines and NEVER in CI -- a silent gate gap, not a failure.
pre-commit cannot check "the commits of this push" on its own, so this
script feeds each commit message to the hook through
`--hook-stage commit-msg --commit-msg-filename <file>`, one commit at a
time, and aggregates the result.
Message files are written INSIDE the workspace (under ci-artifacts/, which
is gitignored) for two hard reasons:
* pre-commit rejects a message path on another drive than the repo
("path is on mount 'C:', start on mount 'D:'" -- measured here);
* they must be UTF-8 WITHOUT BOM, because a BOM counts as a character of
the title and would distort the 100-char rule.
.PARAMETER BaseSha
Exclusive lower bound. Empty, all-zero (branch creation / deletion), or an
unreachable SHA (force push) falls back to the newest -MaxCommits commits.
.PARAMETER HeadSha
Inclusive upper bound. Defaults to HEAD.
.PARAMETER MaxCommits
Upper bound on how many commits are checked, so a big range cannot run for
hours. Merge commits are skipped (their messages are generated by git, and
the local hook is what guards hand-written ones).
.PARAMETER PythonExe
Interpreter that provides pre-commit. Defaults to $env:PYTHON, then to
scripts/ci_find_python.ps1 auto-discovery.
.OUTPUTS
None. Diagnostics go to the host as GitHub workflow commands.
Exit codes: 0 = all messages valid, 1 = at least one invalid, 2 = setup error.
.NOTES
Keep this file pure ASCII (PowerShell 5.1 decodes BOM-less files as CP936).
#>
param(
[string]$BaseSha = '',
[string]$HeadSha = 'HEAD',
[int]$MaxCommits = 50,
[string]$PythonExe = ''
)
$ErrorActionPreference = 'Continue'
$repoRoot = Split-Path -Parent $PSScriptRoot
Push-Location -LiteralPath $repoRoot
try {
# git emits UTF-8 commit messages; PS 5.1 would otherwise decode them with
# the console code page (CP936) and mangle every Chinese title.
$savedConsoleEncoding = [Console]::OutputEncoding
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { }
try {
# --- resolve the interpreter ----------------------------------------
if ([string]::IsNullOrWhiteSpace($PythonExe)) { $PythonExe = $env:PYTHON }
if ([string]::IsNullOrWhiteSpace($PythonExe)) {
$PythonExe = & (Join-Path $PSScriptRoot 'ci_find_python.ps1') -NeedPreCommit
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($PythonExe)) {
Write-Host '::error::gitlint: no Python with pre-commit available'
exit 2
}
}
$PythonExe = "$PythonExe".Trim()
Write-Host "gitlint: using python $PythonExe"
# --- resolve the commit range ---------------------------------------
$range = ''
$cleanBase = "$BaseSha".Trim()
if ($cleanBase -match '^[0-9a-fA-F]{40}$' -and $cleanBase -notmatch '^0+$') {
$probe = "$cleanBase^{commit}"
git cat-file -e $probe 2>$null
if ($LASTEXITCODE -eq 0) { $range = "$cleanBase..$HeadSha" }
else { Write-Host "::warning::gitlint: base $cleanBase is not reachable in this clone -- falling back to the newest $MaxCommits commit(s)" }
}
$savedPref = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
if ($range) {
$shas = git log --no-merges --format=%H $range 2>$null
}
else {
$shas = git log --no-merges --format=%H -n $MaxCommits $HeadSha 2>$null
}
$ErrorActionPreference = $savedPref
$shas = @($shas | Where-Object { $_ -and "$_".Trim() })
if ($shas.Count -eq 0) {
Write-Host '::notice::gitlint: no non-merge commits in range -- nothing to check'
exit 0
}
if ($shas.Count -gt $MaxCommits) {
Write-Host "::warning::gitlint: range has $($shas.Count) commits, checking the newest $MaxCommits"
$shas = $shas[0..($MaxCommits - 1)]
}
# --- message files: inside the workspace, UTF-8 without BOM ---------
$msgDir = Join-Path $repoRoot 'ci-artifacts\gitlint'
New-Item -ItemType Directory -Force -Path $msgDir | Out-Null
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$checked = 0
$failed = 0
foreach ($sha in $shas) {
$sha = "$sha".Trim()
$checked++
$savedPref = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$body = git log -1 --format=%B $sha 2>$null
$ErrorActionPreference = $savedPref
$titleLine = (git log -1 --format=%s $sha 2>$null | Select-Object -First 1)
$msgPath = Join-Path $msgDir "$sha.txt"
$text = (($body | Out-String) -replace "`r`n", "`n").TrimEnd("`n")
[System.IO.File]::WriteAllText($msgPath, $text, $utf8NoBom)
Write-Host "----------------------------------------"
Write-Host "commit $sha title: $titleLine"
$savedPref = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
& $PythonExe -s -m pre_commit run --hook-stage commit-msg `
--commit-msg-filename $msgPath gitlint 2>&1
$rc = $LASTEXITCODE
$ErrorActionPreference = $savedPref
if ($rc -ne 0) {
$failed++
Write-Host "::error::gitlint: commit $($sha.Substring(0, 8)) rejected -- `"$titleLine`""
}
}
$summary = "gitlint: $checked commit message(s) checked, $failed failed"
Write-Host "::notice::$summary"
if ($env:GITHUB_STEP_SUMMARY) {
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value '### Commit message gate (gitlint)' -Encoding utf8
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary -Encoding utf8
}
if ($failed -gt 0) {
Write-Host '::error::commit message gate failed -- rule: single-line Chinese title, < 100 chars (see docs/ pre-commit usage guide + .gitlint)'
exit 1
}
exit 0
}
finally {
try { [Console]::OutputEncoding = $savedConsoleEncoding } catch { }
}
}
finally {
Pop-Location
}
+118 -3
View File
@@ -8,9 +8,16 @@
跑同一套判定,命令与退出码与 CI 一一对应。
覆盖范围(与 CI 完全一致):
L0 静态门禁 = `pre-commit run --all-files`
L0 静态门禁 = `pre-commit run --all-files` + 提交信息门禁(gitlint)
L0.5 主机侧纯逻辑单测 = `test/host/**/run_unit_test.py`
⚠️ gitlint 必须**单独跑**(2026-10-11 审计):`stages: [commit-msg]` 写在 gitlint **上游**
的 `.pre-commit-hooks.yaml`(项目 `.pre-commit-config.yaml` 里查不到这一行),而
`pre-commit run --all-files` 只跑 pre-commit stage —— 它既不在 --all-files 里,
CI 也一度漏掉(18 个钩子只报 17 个,且表现为"全绿")。本脚本与 CI 的
`scripts/ci_gitlint_range.ps1` 同语义:逐条把最近 N 条非 merge 提交的
message 喂给钩子。
刻意**不含**(与 CI 同理由,见 workflow 文件头):
- `@build`(需 Keil 授权;CI 也不跑,真编译是开发者本地红线)
- `mem-numbers` 钩子(事实源 `.map` 是 Keil 产物——**本地若存在 .map 就照常跑**,
@@ -23,6 +30,7 @@
汇总行(S3,2026-10-02,对齐 CI 的 ::notice::):
[SUMMARY] L0: pre-commit hooks -- passed=N failed=M skipped=K
[SUMMARY] gitlint: N commit message(s) checked, M failed
[SUMMARY] L0.5: K runner(s), A/B assertions passed, fail=F
用法(仓库根目录):
@@ -30,6 +38,7 @@
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --l0-only
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --l05-only
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --keep-going # 失败也继续跑完
& '.venv\\Scripts\\python.exe' scripts/ci_local.py --commits 20 # 提交信息校验条数
设计依据:`.gitea/workflows/ci.yml`;docs/测试说明/测试开发标准流程.md §9
创建日期:2026-10-01
@@ -46,6 +55,30 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def _global_python_candidates():
"""常见**机器级** Python 安装位置(必须与 scripts/ci_find_python.ps1 同序)。
两处探测一旦不同步,就会出现"本地能跑、CI 挂"的老毛病:2026-10-11 的
L0 全灭就是 workflow 把候选收窄成 PATH python + 仓库 .venv(runner 上两者
都是死路),而真正装了 pre-commit 的机器级解释器(如
C:\\ProgramData\\anaconda3\\python.exe)不再被探测。
"""
out = []
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
local = os.environ.get("LOCALAPPDATA")
pd = os.environ.get("ProgramData", r"C:\ProgramData")
# 新版本优先:新装的解释器通常才是想要的。
for ver in ("Python313", "Python312", "Python311", "Python310"):
out.append(os.path.join(pf, ver, "python.exe"))
out.append(os.path.join(pf86, ver, "python.exe"))
if local:
out.append(os.path.join(local, "Programs", "Python", ver, "python.exe"))
for dist in ("anaconda3", "miniconda3"):
out.append(os.path.join(pd, dist, "python.exe"))
return out
def _resolve_python():
r"""挑一个真正装了 pre-commit 的解释器。
@@ -56,6 +89,7 @@ def _resolve_python():
2. 当前解释器(sys.executable)
3. 仓库 `.venv\Scripts\python.exe`
4. PATH 上的 `python`
5. 常见机器级安装位置(见 _global_python_candidates)
找到即返回 (python_path, note);都找不到则返回 (当前解释器, None) 交由调用方报错。
"""
explicit = os.environ.get("DTU_PRECOMMIT_PYTHON", "").strip()
@@ -80,12 +114,14 @@ def _resolve_python():
sys.executable,
str(REPO_ROOT / ".venv" / "Scripts" / "python.exe"),
shutil.which("python"),
]
] + _global_python_candidates()
seen = set()
tried = []
for py in candidates:
if not py or py in seen or not os.path.isfile(py):
continue
seen.add(py)
tried.append(py)
try:
cp = subprocess.run([py, "-m", "pre_commit", "--version"],
capture_output=True, timeout=60)
@@ -94,6 +130,9 @@ def _resolve_python():
if cp.returncode == 0:
note = None if py == sys.executable else "(自动选用,本解释器无 pre-commit)"
return py, note
print("[WARN] 未找到装有 pre-commit 的解释器,已尝试:")
for py in tried:
print(" - {}".format(py))
return sys.executable, None
@@ -170,6 +209,75 @@ def run_l0():
return rc
def run_gitlint(count):
"""提交信息门禁:把最近 N 条非 merge 提交的 message 逐条喂给 gitlint 钩子。
与 CI 的 scripts/ci_gitlint_range.ps1 同语义(gitlint 声明
`stages: [commit-msg]`,`pre-commit run --all-files` 永远跑不到它)。
返回退出码(0/1/2)。
"""
print("")
print("=" * 64)
print(" L0b 提交信息门禁(gitlint / commit-msg stage,最近 {} 条)".format(count))
print("=" * 64)
def _git(*args):
return subprocess.run(["git"] + list(args), cwd=str(REPO_ROOT),
capture_output=True, timeout=60)
try:
out = _git("log", "--no-merges", "--format=%H", "-n", str(count), "HEAD")
except (OSError, subprocess.TimeoutExpired) as e:
print("[ERROR] 无法调用 git:{}".format(e))
return 2
if out.returncode != 0:
print("[ERROR] git log 失败:{}".format(
out.stderr.decode("utf-8", errors="replace").strip()))
return 2
shas = [s.strip() for s in out.stdout.decode("utf-8", errors="replace").splitlines()
if s.strip()]
if not shas:
print("[INFO] 无可校验提交 —— 跳过")
print("[SUMMARY] gitlint: 0 commit message(s) checked, 0 failed")
return 0
# 消息文件必须写在【仓库内】且【UTF-8 无 BOM】:
# - 跨盘符会被 pre-commit 直接拒绝(path is on mount 'C:', start on mount 'D:')
# - BOM 会占掉标题 1 个字符,扭曲 .gitlint 的 100 字符上限
msg_dir = REPO_ROOT / "ci-artifacts" / "gitlint-local"
msg_dir.mkdir(parents=True, exist_ok=True)
checked = 0
failed = 0
try:
for sha in shas:
checked += 1
body = _git("log", "-1", "--format=%B", sha)
title = _git("log", "-1", "--format=%s", sha)
text = body.stdout.decode("utf-8", errors="replace").rstrip("\r\n")
title_line = title.stdout.decode("utf-8", errors="replace").strip()
msg_path = msg_dir / (sha + ".txt")
msg_path.write_text(text, encoding="utf-8", newline="\n")
print("")
print("--- {} {}".format(sha[:8], title_line))
rc, _ = _run_capture([PY, "-m", "pre_commit", "run",
"--hook-stage", "commit-msg",
"--commit-msg-filename", str(msg_path),
"gitlint"])
if rc != 0:
failed += 1
print("[FAIL] gitlint 拒绝该提交信息:{}".format(title_line))
finally:
shutil.rmtree(str(msg_dir), ignore_errors=True)
print("[SUMMARY] gitlint: {} commit message(s) checked, {} failed".format(
checked, failed))
return 1 if failed else 0
def run_l05(args):
"""L0.5:主机侧单测。返回退出码(0/1/2)。"""
print("")
@@ -235,6 +343,8 @@ def main(argv=None):
g.add_argument("--l05-only", action="store_true", help="只跑 L0.5 主机侧单测")
ap.add_argument("--keep-going", action="store_true",
help="某步失败也继续跑后续步骤(默认首错即停)")
ap.add_argument("--commits", type=int, default=10, metavar="N",
help="提交信息门禁(gitlint)校验最近 N 条非 merge 提交(默认 10)")
args = ap.parse_args(argv)
print("=== 本地 CI(等价 .gitea/workflows/ci.yml)===")
@@ -263,10 +373,15 @@ def main(argv=None):
try:
if not args.l05_only:
rc = run_l0()
if rc != 0 and not args.keep_going:
# 提交信息门禁独立于 --all-files(stage 不同),故单独跑一段;
# 即使 L0 已失败也照跑,一次看全所有违规项。
rc_msg = run_gitlint(args.commits)
if rc != 0 or rc_msg != 0:
if not args.keep_going:
print("")
print("[FAIL] L0 未通过 —— 中止(--keep-going 可继续跑 L0.5)")
return 1
rc = rc or rc_msg
if not args.l0_only:
rc05 = run_l05(args)
rc = rc or rc05
+534
View File
@@ -0,0 +1,534 @@
<#
.SYNOPSIS
Make the Gitea Actions runner on this machine survive restarts, and give it
a toolchain the SYSTEM service account can actually see.
.DESCRIPTION
Why this file exists (2026-10-11 CI audit, the "many jobs never ran" bug):
Six consecutive runs (16..21, i.e. 12 jobs) produced ZERO log lines. The
runner `win-stm32-ci` was simply offline, and two independent causes made
that permanent:
1. NO SUPERVISION. gitea-runner v4.0.1 `daemon` has no service support
(v4 removed `daemon install`), and it EXITS when the first Declare
call fails. The runner log shows exactly that after a Gitea restart:
level=error msg="fail to invoke Declare" error="unavailable:
dial tcp 127.0.0.1:3001: connectex: ... actively refused it."
Error: unavailable: dial tcp 127.0.0.1:3001 ...
...and the process is gone. So one Gitea restart (or one reboot)
silently kills CI until a human notices days later. Queued runs then
get cancelled by the NEXT push, because ci.yml sets
`concurrency.cancel-in-progress: true` -- which is why the symptom
looks like "tasks vanish" rather than "tasks fail".
2. NO MACHINE-LEVEL TOOLCHAIN. The runner service runs as SYSTEM.
pre-commit / cppcheck were only reachable through the interactive
user's PATH, and no machine-level DTU_* variable was ever set, so
even a live runner died in the L0 environment check.
This script installs the missing supervision (a watchdog launcher + a
scheduled task) and, with -SetEnv, writes the machine-level DTU_* variables
by discovering the tools the same way CI does.
Run elevated, from the repo:
powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv -StartNow
.PARAMETER RunnerDir
Directory holding gitea-runner.exe / config.yaml / .runner.
.PARAMETER GiteaUrl
Instance address the runner registered against (must match .runner's
"address"; the runner talks to it directly, not through the public URL).
.PARAMETER TaskName
Scheduled-task name for the watchdog.
.PARAMETER SetEnv
Discover and write MACHINE-level DTU_PRECOMMIT_PYTHON / DTU_CPPCHECK /
DTU_MSVC_CL. Existing values are preserved unless -Force.
.PARAMETER StartNow
Start the task after registering it.
.PARAMETER Status
Only report: task state, daemon process, Gitea reachability.
.PARAMETER DryRun
Build and validate the action / trigger / settings objects and print what
WOULD be done, writing and registering nothing. This is also the only way
to verify this script without elevation.
.PARAMETER Force
With -SetEnv, overwrite existing machine-level values.
.OUTPUTS
Progress lines. Exit codes: 0 = ok, 1 = precondition failed, 2 = not elevated.
.NOTES
Keep this file pure ASCII (PowerShell 5.1 decodes BOM-less files as CP936).
#>
[CmdletBinding()]
param(
[string]$RunnerDir = 'D:\Docker\act_runner',
[string]$GiteaUrl = 'http://127.0.0.1:3001',
[string]$TaskName = 'GiteaActionsRunner',
[switch]$SetEnv,
[switch]$StartNow,
[switch]$Status,
[switch]$DryRun,
[switch]$Force
)
$ErrorActionPreference = 'Stop'
function Info($m) { Write-Host "[INFO] $m" -ForegroundColor Cyan }
function Ok($m) { Write-Host "[ OK ] $m" -ForegroundColor Green }
function Warn($m) { Write-Host "[WARN] $m" -ForegroundColor Yellow }
function Fail($m) { Write-Host "[FAIL] $m" -ForegroundColor Red }
function Test-Admin {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
(New-Object Security.Principal.WindowsPrincipal($id)
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Test-Gitea([string]$url) {
try {
Invoke-WebRequest -Uri "$url/api/v1/version" -UseBasicParsing -TimeoutSec 10 | Out-Null
return $true
}
catch { return $false }
}
function Get-MachineEnv([string]$name) {
$v = [Environment]::GetEnvironmentVariable($name, 'Machine')
if ([string]::IsNullOrWhiteSpace($v)) { return $null }
return $v
}
function Set-MachineEnv([string]$name, [string]$value, [string]$label) {
$cur = Get-MachineEnv $name
if ($cur) {
if ($cur -ieq $value) { Ok "$label already set: $cur"; return }
if (-not $Force) {
Warn "$label already set to a different value: $cur (use -Force to overwrite)"
return
}
Warn "$label overwriting $cur -> $value"
}
[Environment]::SetEnvironmentVariable($name, $value, 'Machine')
Ok "$label set (MACHINE scope): $value"
}
function Find-Cppcheck {
$pf = $env:ProgramFiles
$pf86 = ${env:ProgramFiles(x86)}
if ([string]::IsNullOrWhiteSpace($pf)) { $pf = 'C:\Program Files' }
if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' }
foreach ($c in @((Join-Path $pf 'Cppcheck\cppcheck.exe'),
(Join-Path $pf86 'Cppcheck\cppcheck.exe'))) {
if (Test-Path -LiteralPath $c) { return $c }
}
$cmd = Get-Command cppcheck -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
return $null
}
function Find-MsvcCl {
$pf = $env:ProgramFiles
$pf86 = ${env:ProgramFiles(x86)}
if ([string]::IsNullOrWhiteSpace($pf)) { $pf = 'C:\Program Files' }
if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' }
$roots = @()
foreach ($year in @('2019', '2022', '2026', '18')) {
foreach ($edition in @('BuildTools', 'Community', 'Professional', 'Enterprise')) {
$roots += (Join-Path $pf86 "Microsoft Visual Studio\$year\$edition")
$roots += (Join-Path $pf "Microsoft Visual Studio\$year\$edition")
}
}
foreach ($r in $roots) {
if (-not (Test-Path -LiteralPath $r)) { continue }
$cl = Get-ChildItem -LiteralPath $r -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like '*Hostx64\x64*' } |
Select-Object -First 1
if ($cl) { return $cl.FullName }
}
return $null
}
# Builds the task objects in ONE place so -DryRun validates exactly what the
# real run registers.
#
# MEASURED TRAP (2026-10-11, hit on the user's machine): `StartWhenAvailable`
# is a TASK SETTINGS property (MSFT_TaskSettings, inherited by
# MSFT_TaskSettings3) -- it does NOT exist on triggers. Assigning it to the
# trigger aborts the script with a SetValueInvocationException along the lines
# of "setting StartWhenAvailable threw an exception: property
# 'StartWhenAvailable' is not found on this object" (message localized),
# and, worse, that happened AFTER the old task had already been unregistered,
# leaving CI with no task at all. Hence: build everything first, only then
# unregister the old task.
function New-WatchdogTaskPlan {
param([string]$Launcher, [string]$WorkDir)
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$Launcher`"" `
-WorkingDirectory $WorkDir
# Boot trigger + a 1 minute grace period: Gitea may still be starting when
# the session-0 boot fires (and the watchdog waits for it anyway).
$trigger = New-ScheduledTaskTrigger -AtStartup
$trigger.Delay = 'PT1M'
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero) `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-MultipleInstances IgnoreNew
# StartWhenAvailable also covers "the machine was off when this was missed".
$settings.StartWhenAvailable = $true
return [pscustomobject]@{ Action = $action; Trigger = $trigger; Settings = $settings }
}
# ---------------------------------------------------------------------------
# -Status: read-only report
# ---------------------------------------------------------------------------
if ($Status) {
Info "task $TaskName"
$t = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($t) {
Ok ("task state: {0}" -f $t.State)
$li = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
if ($li) {
Info ("last run: {0} | last result: {1} | next run: {2}" -f `
$li.LastRunTime, ('0x{0:X}' -f $li.LastTaskResult), $li.NextRunTime)
}
}
else {
Warn "task $TaskName not found"
if (-not (Test-Admin)) {
Warn ' this session is NOT elevated: a non-admin session cannot always see'
Warn ' every task, so re-run -Status elevated before concluding it is missing'
}
}
$proc = @(Get-Process | Where-Object { $_.ProcessName -match 'gitea-runner|act_runner' })
if ($proc.Count -eq 1) {
Ok ("daemon running: pid {0}" -f $proc[0].Id)
} elseif ($proc.Count -gt 1) {
Warn (" {0} daemon processes -- only ONE can hold .runner.lock, the rest are in a restart loop" -f $proc.Count)
Warn ' fix: re-run this script elevated with -StartNow (it clears stale instances first)'
} else { Warn 'daemon not running' }
# A watchdog that cannot get the lock fails every 10s while the runner still
# reports ONLINE, so the loop is invisible from the web UI. Count watchdogs and
# grep the daemon log for the lock error (measured 2026-10-12).
# MEASURED TRAP #3 (2026-10-12): a NON-ELEVATED session cannot read SYSTEM
# process command lines -- CommandLine comes back $null, so the filter below
# silently matches NOTHING and reporting "no watchdog" would be a lie (this
# bit us twice while diagnosing). Say what we cannot see instead.
if (-not (Test-Admin)) {
Info ' watchdog count skipped: needs elevation (SYSTEM command lines are hidden)'
} else {
$wd = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -match 'run_runner_watchdog\.ps1' })
if ($wd.Count -gt 1) {
Warn (" {0} watchdog processes (pids {1}) -- an old one survived a task re-registration" -f `
$wd.Count, (($wd | ForEach-Object { $_.ProcessId }) -join ','))
Warn ' and is racing the new one for .runner.lock; re-run elevated with -StartNow'
} elseif ($wd.Count -eq 1) {
Ok ("watchdog running: pid {0}" -f $wd[0].ProcessId)
} else {
Warn 'no watchdog process found (task not running?)'
}
}
# Restart-loop detection. Do NOT just grep the log tail: the file is never
# rotated, so old failures stay in the last lines forever and a HEALTHY runner
# keeps "reporting" a stale error (measured false positive 2026-10-12). The
# reliable signal is a daemon that keeps dying young -- a process alive longer
# than the 10s restart interval cannot be inside that loop. CIM CreationDate is
# readable without elevation; Process.StartTime is NOT for SYSTEM processes.
$dmon = Get-CimInstance Win32_Process -Filter "Name='gitea-runner.exe'" -ErrorAction SilentlyContinue |
Select-Object -First 1
$upSec = if ($dmon) { [int]((Get-Date) - $dmon.CreationDate).TotalSeconds } else { -1 }
if ($upSec -ge 90) {
Ok ("daemon up for {0} min -- no restart loop" -f [int]($upSec / 60))
} else {
$dlog = Join-Path $RunnerDir 'log\runner-daemon.log'
if (Test-Path -LiteralPath $dlog) {
$tail = Get-Content -LiteralPath $dlog -Tail 40 -Encoding UTF8 -ErrorAction SilentlyContinue
if ($tail -match 'already locked by another process') {
Warn ' daemon keeps dying young and the log shows ".runner is already locked'
Warn ' by another process" -- a stale instance holds the lock; re-run with -StartNow'
}
}
}
if (Test-Gitea $GiteaUrl) { Ok "Gitea reachable at $GiteaUrl" }
else { Fail "Gitea NOT reachable at $GiteaUrl" }
foreach ($n in @('DTU_PRECOMMIT_PYTHON', 'DTU_CPPCHECK', 'DTU_MSVC_CL')) {
$v = Get-MachineEnv $n
if ($v) { Ok ("MACHINE {0} = {1}" -f $n, $v) }
else { Warn "MACHINE $n not set (CI auto-discovery still applies; see scripts/ci_find_python.ps1)" }
}
exit 0
}
# ---------------------------------------------------------------------------
# Preconditions
# ---------------------------------------------------------------------------
if (-not $DryRun -and -not (Test-Admin)) {
Fail 'not elevated -- rerun from an elevated PowerShell (this writes a scheduled task and machine env vars)'
Fail 'use -DryRun to validate the whole plan (action/trigger/settings) without elevation'
exit 2
}
$exe = Join-Path $RunnerDir 'gitea-runner.exe'
$cfg = Join-Path $RunnerDir 'config.yaml'
$regFile = Join-Path $RunnerDir '.runner'
$launcher = Join-Path $RunnerDir 'run_runner_watchdog.ps1'
foreach ($f in @($exe, $cfg)) {
if (-not (Test-Path -LiteralPath $f)) { Fail "missing file: $f"; exit 1 }
}
if (-not (Test-Path -LiteralPath $regFile)) {
Fail "not registered: $regFile is missing"
Info 'register first, e.g.:'
Write-Host " & '$exe' register --no-interactive --instance '$GiteaUrl' --token <RegistrationToken> --name win-stm32-ci --labels 'windows:host'"
exit 1
}
# The daemon must talk to the address baked into .runner, not the public URL.
$registeredAddress = $null
try {
$registeredAddress = (Get-Content -LiteralPath $regFile -Raw | ConvertFrom-Json).address
}
catch { Warn "cannot parse $regFile : $_" }
if ($registeredAddress -and $registeredAddress.TrimEnd('/') -ne $GiteaUrl.TrimEnd('/')) {
Warn ".runner address is $registeredAddress but -GiteaUrl is $GiteaUrl -- using the .runner value for the readiness probe"
$GiteaUrl = $registeredAddress
}
# ---------------------------------------------------------------------------
# 0. build the task plan up front; -DryRun stops here without writing anything
# ---------------------------------------------------------------------------
$plan = New-WatchdogTaskPlan -Launcher $launcher -WorkDir $RunnerDir
if ($DryRun) {
Info 'dry run -- writing and registering NOTHING'
Info ("would write launcher : {0}" -f $launcher)
Info ("would run : {0} {1}" -f $plan.Action.Execute, $plan.Action.Arguments)
Info ("would trigger : {0} delay={1}" -f $plan.Trigger.CimClass.CimClassName, $plan.Trigger.Delay)
Info ("would settings : StartWhenAvailable={0} RestartCount={1} RestartInterval={2} ExecTimeLimit={3} MultipleInstances={4}" -f `
$plan.Settings.StartWhenAvailable, $plan.Settings.RestartCount, $plan.Settings.RestartInterval,
$plan.Settings.ExecutionTimeLimit, $plan.Settings.MultipleInstances)
Info 'would register as : user SYSTEM, RunLevel Highest'
if (Test-Admin) { Ok 'elevation: OK (admin)' }
else { Warn 'elevation: NOT admin -- the real run must be started from an elevated shell' }
if (Test-Gitea $GiteaUrl) { Ok "Gitea reachable at $GiteaUrl" }
else { Warn "Gitea not reachable at $GiteaUrl -- the watchdog would keep waiting" }
exit 0
}
# ---------------------------------------------------------------------------
# 1. machine-level toolchain (SYSTEM cannot see the interactive user's PATH)
# ---------------------------------------------------------------------------
if ($SetEnv) {
Info 'discovering toolchain for MACHINE scope ...'
$findPy = Join-Path $PSScriptRoot 'ci_find_python.ps1'
if (Test-Path -LiteralPath $findPy) {
$py = & $findPy -NeedPreCommit
if ($LASTEXITCODE -eq 0 -and $py) {
Set-MachineEnv 'DTU_PRECOMMIT_PYTHON' ("$py".Trim()) 'DTU_PRECOMMIT_PYTHON'
}
else {
Warn 'no interpreter with pre-commit found -- install pre-commit into a machine-wide interpreter first'
}
}
else { Warn "not found: $findPy (skipping DTU_PRECOMMIT_PYTHON)" }
$cpp = Find-Cppcheck
if ($cpp) { Set-MachineEnv 'DTU_CPPCHECK' $cpp 'DTU_CPPCHECK' }
else { Warn 'cppcheck not found (install Cppcheck.Cppcheck, or set DTU_CPPCHECK manually)' }
$cl = Find-MsvcCl
if ($cl) { Set-MachineEnv 'DTU_MSVC_CL' $cl 'DTU_MSVC_CL' }
else { Warn 'MSVC cl.exe (Hostx64\x64) not found -- L0.5 will degrade to "skipped" without it' }
Info 'machine env vars are read when a PROCESS STARTS: the daemon must be restarted below'
}
# ---------------------------------------------------------------------------
# 2. watchdog launcher (the actual fix for "daemon exits and nobody notices")
# ---------------------------------------------------------------------------
Info "writing watchdog launcher: $launcher"
$template = @'
# AUTO-GENERATED by scripts/ci_runner_bootstrap.ps1 -- do not edit by hand.
#
# gitea-runner `daemon` exits when its first Declare call fails (no retry, no
# service support in v4), so a plain scheduled task is NOT enough: a Gitea
# restart would take CI down until a human noticed. This loop
# 1) waits until Gitea answers, then
# 2) runs the daemon, and
# 3) restarts it after a short pause whenever it exits.
# Keep this file pure ASCII (PowerShell 5.1 decodes BOM-less files as CP936).
$ErrorActionPreference = 'Continue'
$runnerDir = '__RUNNER_DIR__'
$runnerExe = Join-Path $runnerDir 'gitea-runner.exe'
$runnerCfg = Join-Path $runnerDir 'config.yaml'
$logDir = Join-Path $runnerDir 'log'
$log = Join-Path $logDir 'runner-watchdog.log'
$daemonLog = Join-Path $logDir 'runner-daemon.log'
$probeUrl = '__GITEA_URL__/api/v1/version'
$maxWaitSec = 1800
if (-not (Test-Path -LiteralPath $logDir)) {
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
}
function Log($m) {
$line = '{0} [watchdog] {1}' -f (Get-Date -Format 'o'), $m
Add-Content -LiteralPath $log -Value $line -Encoding ascii
}
while ($true) {
$waited = 0
while ($true) {
$ok = $false
try {
Invoke-WebRequest -Uri $probeUrl -UseBasicParsing -TimeoutSec 10 | Out-Null
$ok = $true
}
catch { $ok = $false }
if ($ok) { break }
if ($waited -eq 0) { Log "waiting for Gitea at $probeUrl" }
Start-Sleep -Seconds 15
$waited += 15
if ($waited -ge $maxWaitSec) {
Log 'Gitea still not answering after 30m -- starting anyway so the real error lands in the log'
break
}
}
Log 'starting daemon'
# MEASURED TRAP (2026-10-12, hit by this very script): in PowerShell 5.1 the
# *>> redirection is implemented with Out-File, whose DEFAULT ENCODING IS
# UTF-16LE. Appending that into a file whose other lines come from
# Add-Content -Encoding ascii leaves ONE FILE WITH TWO ENCODINGS, and the
# daemon lines read back as "t i m e = 2 0 2 6 ..." (a NUL after every byte).
# Never rely on a redirection's default encoding -- state it explicitly.
# utf8 (not ascii) because job/commit text can legitimately be non-ASCII.
# MEASURED TRAP #2 (2026-10-12): the daemon writes its log to STDERR, and in
# PS 5.1 a native `2>&1` merge wraps every stderr line into an ErrorRecord.
# Out-File then renders "gitea-runner.exe : <line>" plus a 4-line CategoryInfo
# block -- measured: a 2-line message became 8 lines, so 3 of every 4 lines in
# the daemon log were PowerShell noise, in the very file the docs tell people
# to read first. Stringifying each pipeline item keeps the text and drops the
# decoration (ErrorRecord.ToString() IS the original stderr line) and leaves
# $LASTEXITCODE intact. Verified against a cmd.exe wrapper; same result, and
# this form avoids cmd's quote-stripping rules for spaced paths.
& $runnerExe daemon --config $runnerCfg 2>&1 |
ForEach-Object { "$_" } |
Out-File -FilePath $daemonLog -Append -Encoding utf8
Log ("daemon exited, exit code {0}; restarting in 10s" -f $LASTEXITCODE)
Start-Sleep -Seconds 10
}
'@
$content = $template.Replace('__RUNNER_DIR__', $RunnerDir).Replace('__GITEA_URL__', $GiteaUrl.TrimEnd('/'))
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($launcher, $content, $utf8NoBom)
Ok 'launcher written'
# ---------------------------------------------------------------------------
# 3. scheduled task: start at boot, run as SYSTEM, restart if it dies
# ---------------------------------------------------------------------------
$existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($existing) {
Info "task $TaskName exists -- unregistering to re-register with the current settings"
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
}
try {
Register-ScheduledTask -TaskName $TaskName -Action $plan.Action -Trigger $plan.Trigger `
-Settings $plan.Settings -User 'SYSTEM' -RunLevel Highest `
-Description 'Gitea Actions runner watchdog (STM32F4-Base CI). Supervises the gitea-runner daemon: waits for Gitea, restarts the daemon when it exits. Generated by scripts/ci_runner_bootstrap.ps1.' |
Out-Null
}
catch {
Fail "Register-ScheduledTask failed: $($_.Exception.Message)"
if ($existing) {
Fail "the previous task was ALREADY removed -- fix the cause and re-run this script immediately"
}
Fail 'until this succeeds the runner has no auto-start and CI stays dead'
exit 1
}
Ok "scheduled task registered: $TaskName (at boot +1m, as SYSTEM, unlimited runtime, restart x999/1m)"
# ---------------------------------------------------------------------------
# 4. start it -- but clear stale instances first, or the new watchdog never gets
# the .runner lock.
#
# MEASURED TRAP (2026-10-12): Unregister-ScheduledTask does NOT stop the process
# the old instance had started. That orphaned watchdog keeps its daemon alive and
# the daemon holds .runner.lock, so the freshly started watchdog fails every 10s:
# level=error msg="another gitea-runner process is already using \".runner\"..."
# Error: runner file is already locked by another process
# The runner still shows ONLINE (the stale daemon serves fine), so CI looks healthy
# while the log fills with a restart loop and the task supervises nothing at all.
# ---------------------------------------------------------------------------
function Stop-StaleRunnerInstances {
# Supervisors first: killing the daemon alone lets a live watchdog respawn it
# within 10s and the race starts over.
$killed = 0
Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -match 'run_runner_watchdog\.ps1' } |
ForEach-Object {
Info (" stopping stale watchdog pid {0}" -f $_.ProcessId)
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
$killed++
}
Get-Process -Name 'gitea-runner' -ErrorAction SilentlyContinue | ForEach-Object {
Info (" stopping stale daemon pid {0} (releases .runner.lock)" -f $_.Id)
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
$killed++
}
return $killed
}
if ($StartNow) {
if (-not (Test-Gitea $GiteaUrl)) {
Warn "Gitea is not answering at $GiteaUrl right now -- the watchdog will keep waiting"
}
$stale = Stop-StaleRunnerInstances
if ($stale -gt 0) {
Warn "$stale stale process(es) stopped; the runner is offline for a few seconds"
Start-Sleep -Seconds 2
}
Start-ScheduledTask -TaskName $TaskName
Start-Sleep -Seconds 12 # the watchdog probes Gitea, then execs the daemon
$d = @(Get-Process -Name 'gitea-runner' -ErrorAction SilentlyContinue)
if ($d.Count -eq 1) {
Ok ("daemon up: pid {0} (single instance, holds .runner.lock)" -f $d[0].Id)
} elseif ($d.Count -eq 0) {
Warn 'no daemon yet -- the watchdog may still be waiting for Gitea; check the watchdog log'
} else {
Warn ("{0} daemons running -- only one can hold the lock; inspect and kill the extras" -f $d.Count)
}
Ok "daemon log: Get-Content '$(Join-Path $RunnerDir 'log\runner-daemon.log')' -Tail 20"
Ok "watchdog log: Get-Content '$(Join-Path $RunnerDir 'log\runner-watchdog.log')' -Tail 20"
Info 'verify on the web UI: repo -> Settings -> Actions -> Runners -> win-stm32-ci should turn Idle'
}
Ok 'done'
exit 0