merge: 远程 CI 分支 v2-on-ui-ci 合入 v2-on-ui
CI / L0.5 主机侧单测 (MSVC) (push) Has been cancelled
CI / L0 静态门禁 (pre-commit) (push) Has been cancelled

将 origin/v2-on-ui-ci(13 个提交:远程 CI 文档独立成 docs/CI/、
runner 保活/日志编码/gitlint 空转修复、ci_local L0b 等)合入开发主线。

冲突 5 处,按「两侧意图取并集」解决:
- AGENTS.md / docs/README.md:保留主线 待处理内容.md→任务规划/ 重构,
  并入 CI 分支的 docs/CI/ 索引行与更新日志
- 测试开发标准流程.md §9.1:取 CI 分支,引用改指
  ../CI/远程CI完整指南.md 对应小节
- docs/待处理内容.md:接受主线删除,已重构为 docs/任务规划/
- docs/环境与部署/CI配置指南.md:接受 CI 分支删除,已并入 docs/CI/

合并引入的 doc-links 失效已修(doc-links 合并态走 --sections-strict,
自引用解析失败即硬错误):
- CI 指南 §0.0 标题把 ⚠️ 移到编号后(编号须打头才解析得出)
- CI 指南对 待处理内容.md 的引用改指 docs/任务规划/登记簿.md T-7 条
- CI 指南并入来源《测试开发标准流程》§9.4 / §9.2 改为全路径限定
- 测试开发标准流程 §9.4:§0.2/§2/§2.4/§4.1.3 原与 CI 文档路径间隔
  超过链式继承上限 40 字符,改为紧跟路径并拆分明细句
- 测试开发标准流程 §9.1:步骤级细节引用同样全路径限定到 CI 指南
This commit is contained in:
2026-10-12 19:42:24 +08:00
15 changed files with 2689 additions and 1433 deletions
+7 -1
View File
@@ -36,7 +36,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
+212
View File
@@ -0,0 +1,212 @@
<#
.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):
CI always reports ONE hook fewer than .pre-commit-config.yaml defines
(measured 2026-10-12: master 18 defined / 17 reported, v2-on-ui 26 / 25).
The missing one is `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 (title
< 100 chars, body lines <= 80) 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"
# --- git identity guard ---------------------------------------------
# Measured on CI 2026-10-12 (task 31): actions/checkout points HOME at
# a per-job temp dir and the SYSTEM account has no global identity, so
# gitlint aborted with "Missing git configuration: please set
# user.name" and exit code 254 BEFORE reading a single rule. Every push
# failed this gate no matter how good the message was, and the log made
# it look like a message violation.
# Inject a throwaway identity through GIT_CONFIG_* (git >= 2.31): it is
# inherited by the child git processes only, so NO file is written and
# a developer machine keeps its own identity untouched. gitlint needs
# an identity merely to start; it does not judge the author.
$savedPref = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$idName = git config user.name 2>$null
$idMail = git config user.email 2>$null
$ErrorActionPreference = $savedPref
if (-not "$idName".Trim() -or -not "$idMail".Trim()) {
if ($env:GIT_CONFIG_COUNT) {
Write-Host '::warning::gitlint: GIT_CONFIG_* already set by the caller -- leaving it alone'
} else {
$env:GIT_CONFIG_COUNT = '2'
$env:GIT_CONFIG_KEY_0 = 'user.name'
$env:GIT_CONFIG_VALUE_0 = 'dtu-ci'
$env:GIT_CONFIG_KEY_1 = 'user.email'
$env:GIT_CONFIG_VALUE_1 = 'dtu-ci@invalid.local'
Write-Host '::warning::gitlint: no git identity here -- injected a throwaway one via GIT_CONFIG_* (needed only so gitlint can start)'
}
}
# --- 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
$gateBroken = $false
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'
# ForEach-Object { "$_" } unwraps the ErrorRecords that PS 5.1's
# 2>&1 builds for every native stderr line, without disturbing
# $LASTEXITCODE (docs/CI guide, section 7.3).
$hookOut = & $PythonExe -s -m pre_commit run --hook-stage commit-msg `
--commit-msg-filename $msgPath gitlint 2>&1 | ForEach-Object { "$_" }
$rc = $LASTEXITCODE
$ErrorActionPreference = $savedPref
$hookOut | ForEach-Object { Write-Host $_ }
if ($rc -eq 254 -or ($hookOut -match 'Missing git configuration')) {
# The gate itself could not run. Never report that as a
# message violation -- it is a runner/environment fault and
# needs a different fix (see the identity guard above).
Write-Host "::error::gitlint: GATE BROKEN (exit $rc, git config error) at $($sha.Substring(0, 8)) -- NOT a message violation"
$gateBroken = $true
} elseif ($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 ($gateBroken) {
Write-Host '::error::commit message gate could not RUN -- fix the runner git identity; do NOT read this as a message violation'
exit 2
}
if ($failed -gt 0) {
Write-Host '::error::commit message gate failed -- see the gitlint output above for the exact rule id (rules in .gitlint; AGENTS.md: title <= 100 chars, body lines <= 80)'
exit 1
}
exit 0
}
finally {
try { [Console]::OutputEncoding = $savedConsoleEncoding } catch { }
}
}
finally {
Pop-Location
}
+121 -6
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` / `mem-budget` 钩子(事实源 `.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
@@ -173,6 +212,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("")
@@ -238,6 +346,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)===")
@@ -266,10 +376,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
+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