merge: 远程 CI 分支 v2-on-ui-ci 合入 v2-on-ui
将 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:
+7
-1
@@ -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`,本表只登记不展开。
|
||||
|
||||
## 使用方式
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user