修复CI门禁三因:runner掉线致12个job零日志、L0探测回归全灭、gitlint在CI从不执行

1) runner 掉线:run 16~21 共 12 个 job 零日志。gitea-runner v4 的
   daemon 无服务支持、首次 Declare 失败即退出进程,Gitea/机器重启
   一次 CI 就静默停摆;叠加 cancel-in-progress 后症状是"任务凭空
   消失"。新增 scripts/ci_runner_bootstrap.ps1:等 Gitea 就绪 +
   崩溃自拉起看门狗 + 开机计划任务 + -SetEnv 写 MACHINE 级 DTU_*。
2) L0 自 33a4aa4 起在 runner 上必挂:Python 候选被收窄成 PATH python
   (msys64,无 pre-commit) + 仓库 .venv(gitignore),cppcheck 目录兜底
   亦被删,而机器级 DTU_* 从未设置。探测收进 ci_find_python.ps1
   (两 job 共用、可离线自测、恢复机器级安装兜底)。
3) gitlint 声明 stages:[commit-msg],而 --all-files 只跑 pre-commit
   stage → 17 个钩子定义 CI 只报 16 个且全绿。新增
   ci_gitlint_range.ps1 逐条校验本次范围,ci_local.py 增 L0b 段。
另:GITHUB_ENV/PATH/OUTPUT 写入显式 -Encoding ascii(PS 5.1 的
   Out-File -Append 默认 UTF-16LE,会把带 NUL 的行追加进 UTF-8 文件)
This commit is contained in:
2026-10-12 12:39:27 +08:00
parent c441131db6
commit 20ee35b76a
8 changed files with 1066 additions and 136 deletions
+126 -105
View File
@@ -43,6 +43,34 @@
# 那些由 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` 定义 17 个钩子,CI 只报 16 个(`passed=15 skipped=1`)——
# 缺的正是 gitlint:其钩子声明 `stages: [commit-msg]`,而 `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 +125,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"
exit 1
$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 }
}
}
Write-Host "found cppcheck: $($cpp.Source)"
cppcheck --version
}
if (-not $found) {
Write-Host "::error::runner missing cppcheck (not on SYSTEM PATH, not in the standard install dir)"
Write-Host "::error::set a machine-level DTU_CPPCHECK (see scripts/ci_runner_bootstrap.ps1 -SetEnv) or add its directory to the machine PATH, then re-run"
exit 1
}
$cppDir = Split-Path -Parent $found
$env:Path = "$env:Path;$cppDir"
# ASCII on purpose: Out-File's PS 5.1 default is UTF-16LE, and other
# steps append to this same file -- mixed encodings corrupt it.
$cppDir | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding ascii
& $found --version 2>&1 | Out-Host
Write-Host "runner environment check passed"
@@ -196,6 +199,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 +244,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 +370,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 +381,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 +444,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 日志(供下载/排障)
+4 -1
View File
@@ -34,7 +34,10 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v
- 提交信息:**单行中文标题 <100 字符**(gitlint),UTF-8 无 BOM + `git commit -F <文件>`(防终端转码破坏)
- 代码规范 V1.0:小写下划线命名(`g_`/`s_`/`p_`/`a_` 前缀)、头文件保护宏 `XXX_H`、常量在右、文件头模块注释块 → `docs/代码规范/`
- 全库扫描 `pre-commit run --all-files`;详见 `docs/代码规范/pre-commit使用说明.md`
- **CI(Gitea Actions,2026-10-01 T-6)**:`.gitea/workflows/ci.yml` 在 push/PR 跑 **L0 静态门禁 + L0.5 主机侧单测**(Windows runner);**不含 L1/L2/L3**(需真机+ST-LINK)、**刻意不跑 `@build`**(需 Keil 授权)、无 `.map` 时自适应跳过 `mem-numbers`。本地等价自查:`& '.venv\Scripts\python.exe' scripts/ci_local.py`(**runner 未就绪时这是 L0 的唯一保障**,不能因"已配 CI"放宽本地门禁)。详见 `docs/测试说明/测试开发标准流程.md` §9
- **CI(Gitea Actions,2026-10-01 T-6)**:`.gitea/workflows/ci.yml` 在 push/PR 跑 **L0 静态门禁 + gitlint 提交信息门禁 + L0.5 主机侧单测**(Windows runner);**不含 L1/L2/L3**(需真机+ST-LINK)、**刻意不跑 `@build`**(需 Keil 授权)、无 `.map` 时自适应跳过 `mem-numbers`。本地等价自查:`& '.venv\Scripts\python.exe' scripts/ci_local.py`(**runner 未就绪时这是 L0 的唯一保障**,不能因"已配 CI"放宽本地门禁)。详见 `docs/测试说明/测试开发标准流程.md` §9
- **`pre-commit run --all-files` 跑不到 `gitlint`**(2026-10-11 实测):该钩子声明 `stages: [commit-msg]`,而 `--all-files` 只跑 `pre-commit` stage → **17 个钩子定义、CI 只报 16 个、且表现为全绿**(提交信息门禁曾在 CI 完全空转)。现由 `scripts/ci_gitlint_range.ps1`(CI)与 `ci_local.py` 的 **L0b** 段(本地)逐条校验;**增删钩子后必须核对"定义数 == 日志条数 + gitlint"**
- **CI runner 必须有看门狗**(2026-10-11 实测):`gitea-runner` v4 的 `daemon` **无服务支持、首次 Declare 失败就直接退出进程**(Gitea 重启一次即停摆,run 16~21 共 12 个 job 零日志;叠加 `cancel-in-progress` 后症状是"任务凭空消失"而非失败)。体检 `& '.\scripts\ci_runner_bootstrap.ps1' -Status`;部署 `-SetEnv -StartNow`(看门狗 + 开机计划任务 + MACHINE 级 `DTU_PRECOMMIT_PYTHON`/`DTU_CPPCHECK`/`DTU_MSVC_CL`——runner 以 SYSTEM 跑,看不见用户级 PATH/site)
- **写 `GITHUB_ENV`/`GITHUB_PATH`/`GITHUB_OUTPUT` 必须显式 `-Encoding ascii`**(2026-10-11 实测):PS 5.1 的 `Out-File -Append` **默认 UTF-16LE**,会把 `42 00 42 00 3D 00` 这种带 NUL 的行追加进 runner 以 UTF-8 建的同一文件里
## 平台约束
+20 -5
View File
@@ -530,11 +530,20 @@ flowchart LR
> → `python scripts/check_mem_numbers.py --scan`。详见
> [`../环境与部署/CI配置指南.md`](../环境与部署/CI配置指南.md) §0.0。
> 🔴 **`gitlint` 属 `commit-msg` stage,`--all-files` 永远跑不到它(2026-10-11 审计)**:
> `.pre-commit-config.yaml` 定义 **17** 个钩子,而 CI 日志只报 **16** 个(`passed=15 skipped=1`)——
> 缺的正是 gitlint(其钩子声明 `stages: [commit-msg]`,而 `pre-commit run --all-files` 只跑
> `pre-commit` stage)。即"单行中文标题 <100 字符"这条门禁**本地有、CI 无**,且表现为**全绿**
> 而非失败,所以长期无人发现。现由 **`scripts/ci_gitlint_range.ps1`** 逐条校验本次 push/PR
> 引入的提交(PR 用 `base..head`;push 用 `event.before..sha`,`before` 全零/不可达时回退最近
> N 条;跳过 merge 提交)。本地等价:`ci_local.py` 的 **L0b** 段。
### 9.2 两个 job
| job | `runs-on` | 步骤要点 |
|---|---|---|
| `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` 汇总行 → 归档日志说明 |
| `l0-gates` | `windows` | checkout(`fetch-depth: 0`,gitlint/cubemx 需完整历史)→ 环境自检(**Python 探测走 `scripts/ci_find_python.ps1`**、cppcheck 按 `DTU_CPPCHECK` → PATH → 标准安装目录,缺则 `::error::` + exit 1)→ `pre_commit install --install-hooks` → **提交信息门禁 `scripts/ci_gitlint_range.ps1`**(见上方 🔴)→ **决定跳过哪些钩子**(探测 `.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 全红") |
**结果可见性(S3,2026-10-02)**:两个 job 均写 `$GITHUB_STEP_SUMMARY`(job 运行页的
@@ -551,13 +560,17 @@ step-summary 达成。
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` 环境错误。
@@ -570,6 +583,8 @@ CI 只在 push / PR 时跑,且需 runner 就绪。**提交前自查、或 runn
|---|---|
| Gitea Actions | 实例开启 `[actions] ENABLED = true`(site admin 配置) |
| Windows runner | 已注册且 **labels 含 `windows`**;装有 Python 3.x、cppcheck、pre-commit、MSVC(VS2019/2022 C++ 工具链 + Windows SDK) |
| **runner 必须有看门狗** | `gitea-runner` v4 的 `daemon` **无服务支持、且首次 Declare 失败就直接退出进程** → Gitea 重启一次,CI 就静默停摆(2026-10-11 实测:run 16~21 共 12 个 job 零日志)。用 **`scripts/ci_runner_bootstrap.ps1`** 装"等 Gitea 就绪 + 崩溃自拉起"的看门狗 + 计划任务 |
| 机器级工具变量 | runner 以 **SYSTEM** 运行,看不见交互用户的 PATH/用户级 site。用 `ci_runner_bootstrap.ps1 -SetEnv` 写入 **MACHINE** 级 `DTU_PRECOMMIT_PYTHON` / `DTU_CPPCHECK` / `DTU_MSVC_CL` |
| 缺 MSVC 时 | L0.5 降级为 WARNING 跳过(不硬失败),但**等于该层裸奔**,须尽快补装 |
> 📖 **配置步骤 / 排障**(服务端开关、runner 注册命令、PATH 与 MSVC 路径要求、
+98 -19
View File
@@ -160,20 +160,54 @@ cache:
enabled: true
```
### 2.4 以服务方式常驻
### 2.4 常驻方式:**必须用看门狗,不能只起 daemon**(2026-10-11 实测重写)
> 🔴 **`daemon install` 在 v4 已不存在**:本机 runner 是 `gitea-runner v4.0.1`,命令集只有
> `bug-report / cache-server / config / daemon / exec / register`,**没有任何 Windows 服务
> 管理代码**(`daemon` 是纯控制台程序,无法向 SCM 回报 `SERVICE_RUNNING`)。
> 旧版本文写的 `act_runner.exe daemon install` 在本机**不可用**。
> 🔴🔴 更关键的坑:**`daemon` 在首次 Declare 失败时直接退出进程**(不重试)。
> runner 日志(`D:\Docker\act_runner\log\runner-stdout.log`)实测:
> ```
> 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 ...
> ```
> 之后**没有**任何重试记录——进程已经没了。于是 **Gitea 重启一次 / 机器重启一次,
> CI 就静默停摆**:2026-10-08 起 runner 掉线,run **16~21 共 12 个 job 一行日志都没产生**;
> 又因 `ci.yml` 的 `concurrency.cancel-in-progress: true`,队列里的 run 会被**下一次 push 直接取消**
> (16~20 = `cancelled`、21 = 长期 `queued`)——症状看起来是"任务凭空消失",而不是"任务失败",
> 极难从 UI 反推根因。
**正确做法:一条命令装好"等 Gitea 就绪 + 崩溃自拉起 + 开机自启"**(管理员权限,仓库根目录):
```powershell
# 注册为 Windows 服务(管理员权限)
.\act_runner.exe daemon install
.\act_runner.exe daemon start
# 或前台调试(先看日志通不通,推荐首次这样跑)
.\act_runner.exe daemon
powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv -StartNow
```
它做三件事(细节见脚本头注释):
| 动作 | 内容 |
|---|---|
| ① 机器级工具变量 | 按 CI 同一套探测写 **MACHINE** 级 `DTU_PRECOMMIT_PYTHON` / `DTU_CPPCHECK` / `DTU_MSVC_CL`(已存在则跳过,`-Force` 才覆盖) |
| ② 看门狗启动器 | 生成 `D:\Docker\act_runner\run_runner_watchdog.ps1`:先轮询 `<GiteaUrl>/api/v1/version` 直到 Gitea 应答,再起 `daemon`;daemon 退出 → 记日志 → 10s 后重拉,**永不放弃** |
| ③ 计划任务 | `GiteaActionsRunner`:开机 +1 分钟触发、以 **SYSTEM** + 最高权限运行、**运行时限=不限**、失败每 1 分钟重启(×999)、不允许多实例 |
随时体检(只读,不改任何东西):
```powershell
powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -Status
```
输出会告诉你:计划任务是否存在、daemon 是否在跑、Gitea 是否可达、三个机器级变量是否已设。
注册成功后,回到 **仓库 → Settings → Actions → Runners**,应看到 `win-stm32-ci`
状态为 **Idle / 绿色**。
> ⚠️ **`.runner` 里的 `address` 是 runner 实际连接的地址**(本机 `http://127.0.0.1:3001`),
> 与 `ROOT_URL`(`http://gitea.bitnasdaq.vip/`)不是一回事;bootstrap 会以 `.runner` 为准。
---
## 3. Runner 软件环境:装什么
@@ -189,10 +223,35 @@ runner 是一台干净的 Windows,**下面这些必须装**,否则 job 会
| Keil / ST-LINK | ⛔ 不需要 | CI 刻意不跑 `@build` | — |
| 开发板 / 串口 | ⛔ 不需要 | L1/L2/L3 不上 CI | — |
### 3.1 关键:PATH 必须对
### 3.1 关键:变量必须对 **SYSTEM** 可见(不是"对你可见")
CI 的环境自检用的是 `Get-Command python` / `cppcheck`,**依赖 PATH**。
装完后**新开一个终端**(或重启 runner 服务)让 PATH 生效,然后自检:
runner 以 **SYSTEM 服务/计划任务**运行,它看到的 PATH 与 site-packages **和你登录时完全不同**:
| 你以为 | SYSTEM 实际看到 | 后果 |
|---|---|---|
| `python` 在 PATH 里 | PATH 上是 `C:\msys64\ucrt64\bin\python.exe`,**没有 pre-commit** | L0 环境自检失败 |
| `pip install --user pre-commit` 装好了 | **用户级 site 对 SYSTEM 完全不可见**(换任何 python 路径都没用) | 同上 |
| 仓库里有 `.venv` | `.venv` 已 `.gitignore`,runner 全新工作区**根本没有** | 同上 |
| `cppcheck` 在 PATH 里 | 机器 PATH 无 `C:\Program Files\Cppcheck` | L0 环境自检失败 |
CI 侧的探测统一走 **`scripts/ci_find_python.ps1`**(两个 job 共用、可离线自测):
```
DTU_PRECOMMIT_PYTHON(显式;设了就必须有效,不静默回退)
-> PATH python -> 仓库 .venv -> 常见机器级安装(Program Files\Python3xx、
%LOCALAPPDATA%\Programs\Python\Python3xx、%ProgramData%\anaconda3|miniconda3)
```
cppcheck 同理:`DTU_CPPCHECK` → PATH → `%ProgramFiles%\Cppcheck\cppcheck.exe`
(及 `%ProgramFiles(x86)%\Cppcheck\cppcheck.exe`)。
**推荐做法**:跑一次 bootstrap,把机器级变量固化下来(探测逻辑与 CI 完全一致):
```powershell
powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv
```
手工自检(三条都必须对 SYSTEM 成立,而不只是对你的终端成立):
```powershell
python --version # 期望 Python 3.x
@@ -200,6 +259,9 @@ cppcheck --version # 期望 Cppcheck 2.21.0 或更高
python -m pre_commit --version
```
> ⚠️ 改了机器级变量后**必须重启 runner**(计划任务:`Stop-ScheduledTask` + `Start-ScheduledTask`)
> ——环境变量只在**进程启动时**读取,daemon 不重启就吃不到新值。
### 3.2 MSVC 的路径要求(探测逻辑)
`l05-host-unit-tests` 的 MSVC 探测优先读机器级 `DTU_MSVC_CL`(x64 `cl.exe` 完整路径;
@@ -316,13 +378,15 @@ github.com 会在 checkout 步骤卡住/失败。三种处置:
| # | 检查 | 期望 |
|---|---|---|
| 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 全绿 |
| 2 | Settings → Actions → Runners 里 runner 为 **Idle** | runner 注册成功且**在线**(`offline` = 后面全都不会跑,先做第 3 步) |
| 3 | `scripts\ci_runner_bootstrap.ps1 -Status` | 计划任务已注册 / daemon 在跑 / Gitea 可达 / 三个 `DTU_*` 已设——**四项全绿** |
| 4 | runner 上 `python --version` / `cppcheck --version` / `python -m pre_commit --version` | 三条全通(且对 SYSTEM 成立,见 §3.1) |
| 5 | `git push` 一次(或页面 Run workflow) | Actions 页出现运行中任务 |
| 6 | `l0-gates` 日志 | `using python (…)` + `runner environment check passed`;`::notice::L0: pre-commit hooks -- passed=.. failed=0 skipped=..`;无 `.map` 时见 `::warning::` 体积门禁未跑 |
| 7 | `l0-gates` 的 **提交信息门禁** 步骤 | `::notice::gitlint: N commit message(s) checked, 0 failed`(**这一行以前根本不存在**,见 §6) |
| 8 | `l05-host-unit-tests` 日志 | `::notice::L0.5: 3 runner(s), 278/278 assertions passed, fail=0` |
| 9 | job 运行页的 **Summary** 区块 | 出现 "L0 static gates" / "Commit message gate (gitlint)" / "L0.5 host-side unit tests" 小标题与汇总行 |
| 10 | 提交记录旁出现 ✅ | CI 全绿 |
**本地先自查**(与 CI 同一套判定,避免推上去才发现问题):
@@ -336,6 +400,11 @@ github.com 会在 checkout 步骤卡住/失败。三种处置:
| 现象 | 根因 | 处置 |
|---|---|---|
| 运行状态是 **`cancelled`** 且**一行日志都没有** | runner 掉线时 run 只在队列里等,**下一次 push 触发 `cancel-in-progress` 把它们取消**——所以表现为"任务凭空消失"而不是"任务失败" | 先按下一行修 runner;被取消的 run 可在页面 Re-run,或重新 push |
| Runners 页 runner 显示 **`offline`** | daemon 进程不在:`gitea-runner` v4 的 `daemon` **首次 Declare 失败就直接退出**(Gitea 重启/机器重启后无人拉起) | §2.4 用 `ci_runner_bootstrap.ps1` 装看门狗 + 计划任务;`-Status` 复查 |
| `l0-gates` 报 `no Python with a SYSTEM-visible pre-commit` | runner 是 SYSTEM:PATH 上的 python 无 pre-commit、用户级 site 不可见、`.venv` 未入库(§3.1 三重坑) | `ci_runner_bootstrap.ps1 -SetEnv` 写机器级 `DTU_PRECOMMIT_PYTHON`,或把 pre-commit 装进机器级解释器;**改完必须重启 runner** |
| `l0-gates` 报 `runner missing cppcheck` | 机器 PATH 无 Cppcheck 且无机器级 `DTU_CPPCHECK` | `-SetEnv`(会自动发现 `C:\Program Files\Cppcheck`)或装 `Cppcheck.Cppcheck` |
| **钩子条数对不上**(定义 17、CI 只报 16) | `gitlint` 声明 `stages: [commit-msg]`,`pre-commit run --all-files` **跑不到它**,且表现为全绿——静默空转 | 已由 `scripts/ci_gitlint_range.ps1` 单独校验;**以后增删钩子必须核对"定义数 == 日志条数 + gitlint"** |
| 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) |
@@ -353,9 +422,17 @@ github.com 会在 checkout 步骤卡住/失败。三种处置:
## 7. 日常维护
- **升级 runner**:停服务 → 换 `act_runner.exe` → 起服务(注册信息保留在 `config.yaml`);
- **改了 `VS_ROOTS` / 工具链探测逻辑**:记得**同步改 workflow 里的探测根目录**(两处一致);
- **每周(或每次改完 Gitea/机器重启后)体检**:`scripts\ci_runner_bootstrap.ps1 -Status`——
计划任务 / daemon 进程 / Gitea 可达 / 三个机器级 `DTU_*`,四项全绿才算 CI 活着;
- **升级 runner**:`Stop-ScheduledTask GiteaActionsRunner` → 换 `gitea-runner.exe` →
`Start-ScheduledTask GiteaActionsRunner`(注册信息在 `.runner`,配置在 `config.yaml`);
若换了目录/地址,重跑 `ci_runner_bootstrap.ps1` 重新生成看门狗与计划任务;
- **改了工具链探测逻辑**:Python 侧只有一处事实源 **`scripts/ci_find_python.ps1`**
(两个 job 共用),本地 `scripts/ci_local.py` 的候选表须与它**同序**;
MSVC 探测仍是 workflow 内联 + `test/host/common/unit_test_harness.py` **两处一致**;
- **钩子集变化**(`.pre-commit-config.yaml` 增删钩子):CI 自动跟随,无需改 workflow;
但**必须核对条数**——`pre-commit run --all-files` 只跑 `pre-commit` stage,
`commit-msg` stage 的钩子(gitlint)不在其中,靠 `ci_gitlint_range.ps1` 单独守(见 §6);
- **`@build` / L1 / L2**:永远留在本地,不要往 CI 加(需 Keil 授权与真机);
- **runner 长时间不可用**时:CI 只是"多一道网",**提交前的本地 `ci_local.py` 仍是 L0 的唯一保障**,
不能因"已经配了 CI"放宽(见 `docs/待处理内容.md` 的部署前置警示块)。
@@ -366,6 +443,8 @@ github.com 会在 checkout 步骤卡住/失败。三种处置:
| 日期 | 变更 |
|---|---|
| 2026-10-11 | **修"很多任务不执行"(runner 静默停摆)+ 补 gitlint 门禁**:① §2.4 重写——`gitea-runner` v4 的 `daemon` **无服务支持、首次 Declare 失败即退出进程**(日志实证),Gitea/机器重启一次 CI 就停摆,**run 16~21 共 12 个 job 零日志**(`cancelled`/`queued`,叠加 `cancel-in-progress` 后表现为"任务凭空消失"而非失败);新增 **`scripts/ci_runner_bootstrap.ps1`**(等 Gitea 就绪 + 崩溃自拉起的看门狗 + 开机计划任务 + `-SetEnv` 写机器级 `DTU_*` + `-Status` 只读体检)。② §3.1 重写——SYSTEM 可见性三重坑(PATH python 是 msys64 无 pre-commit / 用户级 site 对 SYSTEM 不可见 / `.venv` 已 gitignore)是 run 14~15 L0 全灭根因(`33a4aa4` 把候选收窄成这两个、又删了 cppcheck 目录兜底,而机器级 `DTU_*` 从未设置);探测收进 **`scripts/ci_find_python.ps1`**(两 job 共用、可离线自测、失败列出全部候选)并恢复机器级安装兜底。③ 新增 **gitlint 提交信息门禁**:钩子定义 **17** 个而 CI 只报 **16** 个,缺的正是声明 `stages: [commit-msg]` 的 gitlint(`--all-files` 跑不到,且表现为全绿 → 长期无人发现)→ **`scripts/ci_gitlint_range.ps1`** 逐条校验本次 push/PR 范围(消息文件须写在仓库内 + UTF-8 无 BOM)。④ `GITHUB_ENV/PATH/OUTPUT` 写入统一显式 `-Encoding ascii`(PS 5.1 `Out-File -Append` 默认 **UTF-16LE**,实测落盘 `42 00 42 00 42 00 3D 00 31 00`)。§5 清单 8→10 条、§6 排障表 +5 行、§7 维护项重写 |
|---|---|
| 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 拉取 / 端到端验证清单 / 故障排查 / 日常维护) |
+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
}
+120 -6
View File
@@ -8,9 +8,15 @@
跑同一套判定,命令与退出码与 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]`,
而 `pre-commit run --all-files` 只跑 pre-commit stage —— 它既不在 --all-files 里,
CI 也一度漏掉(17 个钩子只报 16 个,且表现为"全绿")。本脚本与 CI 的
`scripts/ci_gitlint_range.ps1` 同语义:逐条把最近 N 条非 merge 提交的
message 喂给钩子。
刻意**不含**(与 CI 同理由,见 workflow 文件头):
- `@build`(需 Keil 授权;CI 也不跑,真编译是开发者本地红线)
- `mem-numbers` 钩子(事实源 `.map` 是 Keil 产物——**本地若存在 .map 就照常跑**,
@@ -23,6 +29,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 +37,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 +54,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 +88,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 +113,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 +129,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 +208,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 +342,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 +372,15 @@ def main(argv=None):
try:
if not args.l05_only:
rc = run_l0()
if rc != 0 and not args.keep_going:
print("")
print("[FAIL] L0 未通过 —— 中止(--keep-going 可继续跑 L0.5)")
return 1
# 提交信息门禁独立于 --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
+352
View File
@@ -0,0 +1,352 @@
<#
.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 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]$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
}
# ---------------------------------------------------------------------------
# -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) } else { Warn 'task not registered' }
$proc = Get-Process | Where-Object { $_.ProcessName -match 'gitea-runner|act_runner' }
if ($proc) { Ok ("daemon running: pid(s) " + ($proc.Id -join ',')) } else { Warn 'daemon not running' }
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 (Test-Admin)) {
Fail 'not elevated -- rerun from an elevated PowerShell (this writes a scheduled task and machine env vars)'
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
}
# ---------------------------------------------------------------------------
# 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-stdout.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'
& $runnerExe daemon --config $runnerCfg *>> $log
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
}
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$launcher`"" `
-WorkingDirectory $RunnerDir
# -AtStartup needs the "System" account; a boot trigger plus StartWhenAvailable
# also covers "machine was off when the task was created".
$trigger = New-ScheduledTaskTrigger -AtStartup
$trigger.Delay = 'PT1M'
$trigger.StartWhenAvailable = $true
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero) `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Settings $settings -User 'SYSTEM' -RunLevel Highest `
-Description 'Gitea Actions runner watchdog (STM32F4-Base CI). Supervises gitea-runner daemon: waits for Gitea, restarts the daemon when it exits. Generated by scripts/ci_runner_bootstrap.ps1.' |
Out-Null
Ok "scheduled task registered: $TaskName (at boot +1m, as SYSTEM, unlimited runtime, restart x999/1m)"
# ---------------------------------------------------------------------------
# 4. start it
# ---------------------------------------------------------------------------
if ($StartNow) {
if (-not (Test-Gitea $GiteaUrl)) {
Warn "Gitea is not answering at $GiteaUrl right now -- the watchdog will keep waiting"
}
Start-ScheduledTask -TaskName $TaskName
Ok "task started; watch: Get-Content '$(Join-Path $RunnerDir 'log\runner-stdout.log')' -Tail 20"
Info 'verify on the web UI: repo -> Settings -> Actions -> Runners -> win-stm32-ci should turn Idle'
}
Ok 'done'
exit 0