From 20ee35b76a653039b95a2eebaedd857d87b04116 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Mon, 12 Oct 2026 12:39:27 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8DCI=E9=97=A8=E7=A6=81?= =?UTF-8?q?=E4=B8=89=E5=9B=A0=EF=BC=9Arunner=E6=8E=89=E7=BA=BF=E8=87=B412?= =?UTF-8?q?=E4=B8=AAjob=E9=9B=B6=E6=97=A5=E5=BF=97=E3=80=81L0=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B=E5=9B=9E=E5=BD=92=E5=85=A8=E7=81=AD=E3=80=81gitlint?= =?UTF-8?q?=E5=9C=A8CI=E4=BB=8E=E4=B8=8D=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) runner 掉线:run 16~21 共 12 个 job 零日志。gitea-runner v4 的 daemon 无服务支持、首次 Declare 失败即退出进程,Gitea/机器重启 一次 CI 就静默停摆;叠加 cancel-in-progress 后症状是"任务凭空 消失"。新增 scripts/ci_runner_bootstrap.ps1:等 Gitea 就绪 + 崩溃自拉起看门狗 + 开机计划任务 + -SetEnv 写 MACHINE 级 DTU_*。 2) L0 自 33a4aa4 起在 runner 上必挂:Python 候选被收窄成 PATH python (msys64,无 pre-commit) + 仓库 .venv(gitignore),cppcheck 目录兜底 亦被删,而机器级 DTU_* 从未设置。探测收进 ci_find_python.ps1 (两 job 共用、可离线自测、恢复机器级安装兜底)。 3) gitlint 声明 stages:[commit-msg],而 --all-files 只跑 pre-commit stage → 17 个钩子定义 CI 只报 16 个且全绿。新增 ci_gitlint_range.ps1 逐条校验本次范围,ci_local.py 增 L0b 段。 另:GITHUB_ENV/PATH/OUTPUT 写入显式 -Encoding ascii(PS 5.1 的 Out-File -Append 默认 UTF-16LE,会把带 NUL 的行追加进 UTF-8 文件) --- .gitea/workflows/ci.yml | 231 +++++++++++--------- AGENTS.md | 5 +- docs/测试说明/测试开发标准流程.md | 25 ++- docs/环境与部署/CI配置指南.md | 117 ++++++++-- scripts/ci_find_python.ps1 | 179 +++++++++++++++ scripts/ci_gitlint_range.ps1 | 167 ++++++++++++++ scripts/ci_local.py | 126 ++++++++++- scripts/ci_runner_bootstrap.ps1 | 352 ++++++++++++++++++++++++++++++ 8 files changed, 1066 insertions(+), 136 deletions(-) create mode 100644 scripts/ci_find_python.ps1 create mode 100644 scripts/ci_gitlint_range.ps1 create mode 100644 scripts/ci_runner_bootstrap.ps1 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 90eb771d..a7181475 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -43,6 +43,34 @@ # 那些由 Gitea 按 UTF-8 读,不喂给 PowerShell,安全)。 # ⚠️ 与 AGENTS.md「字符串字面量必须纯 ASCII」同源;改脚本时务必守住。 # +# 🔴🔴 工具链探测必须【可离线自测 + 保留机器级安装兜底】(2026-10-11 第三次实测修正): +# `33a4aa4`(DTU_* 变量改造)把 Python 探测内联进本文件,并把候选**收窄成两个** +# (PATH python + 仓库 .venv)——而 runner 上这两个都是死路: +# · SYSTEM 账号的 PATH python = `C:\msys64\ucrt64\bin\python.exe`,**没有** pre-commit; +# · 仓库 `.venv` 已 gitignore,runner 的全新工作区里**根本不存在**。 +# 唯一可用的机器级解释器(`C:\ProgramData\anaconda3\python.exe`)不再被探测,而文档 +# 要求的机器级 `DTU_PRECOMMIT_PYTHON` 从未设置(Machine 作用域一个 DTU_* 都没有) +# → run 14/15 的 L0 **全部死在"环境自检",一个钩子都没跑**。cppcheck 同理: +# 旧写法"不在 PATH 就把 C:\Program Files\Cppcheck 加进 PATH"的兜底也被删了。 +# **故探测逻辑搬进 `scripts/ci_find_python.ps1`**(两个 job 共用、可离线跑、失败时 +# 打印全部候选),并恢复常见机器级安装位置兜底;`DTU_*` 仍是最高优先级、 +# 且"显式设置就必须有效、不静默回退"的语义不变。 +# +# 🔴 gitlint 属 commit-msg stage,`--all-files` **永远跑不到它**(2026-10-11 审计): +# `.pre-commit-config.yaml` 定义 17 个钩子,CI 只报 16 个(`passed=15 skipped=1`)—— +# 缺的正是 gitlint:其钩子声明 `stages: [commit-msg]`,而 `pre-commit run --all-files` +# 只跑 pre-commit stage。即 AGENTS.md 的"单行中文标题 <100 字符"门禁**本地有、CI 无**, +# 且表现为"全绿"而非失败,所以一直没人发现。现由 `scripts/ci_gitlint_range.ps1` +# 逐条校验本次 push/PR 引入的提交(消息文件必须写在**仓库内**且 **UTF-8 无 BOM**: +# 跨盘符会被 pre-commit 拒绝 `path is on mount 'C:', start on mount 'D:'`, +# BOM 会占掉标题 1 个字符、扭曲 100 上限)。 +# +# 🔴 写 `GITHUB_ENV` / `GITHUB_PATH` / `GITHUB_OUTPUT` 必须显式 `-Encoding ascii` +# (2026-10-11 实测):PS 5.1 的 `Out-File -Append` **默认 UTF-16LE**——往 runner +# 用 UTF-8 建的同一个文件里追加,落盘就是 `B\x00B\x00B\x00=\x001\x00` 这种带 NUL 的行 +# (实测字节:`42 00 42 00 42 00 3D 00 31 00`)。本文件写入的这些键值全是 ASCII, +# 故统一显式 ascii;`GITHUB_STEP_SUMMARY` 是 Markdown,保持 utf8。 +# # ⚠️ 前置(部署到 Gitea 时需确认): # - 该 Gitea 实例已启用 Actions:`[actions] ENABLED = true`(site admin 配置) # - 已注册一个 **Windows** runner(labels 含 `windows`),且装有: @@ -97,98 +125,73 @@ jobs: # NOTE: keep every run: block pure ASCII. The runner writes this block # to a BOM-less .ps1 executed by Windows PowerShell 5.1, which decodes # it as GBK -- non-ASCII bytes there break string quoting. - $repoRoot = (Get-Location).Path - # --- pick a Python that actually has pre-commit ------------------- - # The runner runs as SYSTEM, whose PATH is NOT the dev user's PATH. - # Probe order per the DTU_* env var plan (kept in sync with - # scripts/ci_local.py): - # DTU_PRECOMMIT_PYTHON (explicit; invalid value = hard error) - # -> PATH python -> repo .venv + # Discovery lives in scripts/ci_find_python.ps1 so that + # * both jobs share ONE implementation, + # * it can be run (and its failure list inspected) offline, + # * the well-known machine-wide install locations stay probed. + # The previous inline 2-candidate probe is what killed every L0 run + # since 33a4aa4 -- see the file header. # - # CAUTION 1: a failing native command writes to stderr; with - # $ErrorActionPreference='Stop' that would abort the whole script - # (NativeCommandError) before the next candidate is tried. So relax - # the preference around each probe, and restore it afterwards. - # - # CAUTION 2: pre-commit may be installed in the USER site of some - # account (e.g. %APPDATA%\Roaming\Python\...), which SYSTEM cannot - # see at all -- no python.exe path can help then. Probe with `-s` - # (no user-site) so we only accept an interpreter that works for - # SYSTEM, and say so clearly when none is found. - $py = $null - $saved = $ErrorActionPreference - $envPy = $env:DTU_PRECOMMIT_PYTHON - if ($envPy) { - if (-not (Test-Path $envPy)) { - $ErrorActionPreference = 'Stop' - Write-Host "::error::DTU_PRECOMMIT_PYTHON is set but does not exist: $envPy" - Write-Host "::error::fix the variable (full path to a python.exe with pre-commit), or unset it to use auto-discovery" - exit 1 - } - $ErrorActionPreference = 'Continue' - & $envPy -s -m pre_commit --version 2>&1 | Out-Null - $rc = $LASTEXITCODE - $ErrorActionPreference = $saved - if ($rc -ne 0) { - $ErrorActionPreference = 'Stop' - Write-Host "::error::DTU_PRECOMMIT_PYTHON does not have a SYSTEM-visible pre-commit: $envPy" - exit 1 - } - $py = $envPy - } else { - $candidates = @( - (Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source), - (Join-Path $repoRoot '.venv\Scripts\python.exe') - ) - foreach ($c in $candidates) { - if (-not $c) { continue } - if (-not (Test-Path $c)) { continue } - $ErrorActionPreference = 'Continue' - & $c -s -m pre_commit --version 2>&1 | Out-Null - $rc = $LASTEXITCODE - $ErrorActionPreference = $saved - if ($rc -eq 0) { $py = $c; break } - } - } - $ErrorActionPreference = 'Stop' + # The script prints diagnostics with Write-Host and emits ONLY the + # chosen path on the success stream, so capturing it is exact. + # It also appends PYTHON= to GITHUB_ENV (as ASCII) for later + # steps, which is why $env:PYTHON works in the steps below. + $py = & './scripts/ci_find_python.ps1' -NeedPreCommit -WriteEnv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (-not $py) { - Write-Host "::error::no Python with a SYSTEM-visible pre-commit (DTU_PRECOMMIT_PYTHON not set; tried PATH python and repo .venv)" - Write-Host "::error::pre-commit is probably installed only in a USER site (invisible to SYSTEM)." - Write-Host "::error::fix: set a MACHINE-level DTU_PRECOMMIT_PYTHON for the runner service and restart it, or install pre-commit globally for the SYSTEM account (see the Gitea runner deployment guide in docs/)" - exit 1 + Write-Host "::error::ci_find_python.ps1 succeeded but returned no path" + exit 2 } - Write-Host "using python: $py" - & $py --version - & $py -s -m pre_commit --version - "PYTHON=$py" | Out-File -FilePath $env:GITHUB_ENV -Append + $py = "$py".Trim() # --- cppcheck ----------------------------------------------------- - # Order per the DTU_* plan: DTU_CPPCHECK (explicit; invalid = hard - # error, no fallback) -> PATH (Get-Command cppcheck). No fixed - # install-dir guess: a USER-scope PATH entry is invisible to SYSTEM, - # so on the runner set a MACHINE-level DTU_CPPCHECK if cppcheck is - # not on SYSTEM's PATH. + # Order: DTU_CPPCHECK (explicit; invalid = hard error, no fallback) + # -> PATH -> the standard install directory. + # + # The directory fallback is NOT cosmetic: the runner service runs as + # SYSTEM, whose PATH has no Cppcheck entry (a USER-scope PATH entry is + # invisible to it), and no machine-level DTU_CPPCHECK is set on a + # stock runner. Dropping this fallback in 33a4aa4 is what made L0 die + # on cppcheck as well, right after the Python probe had been fixed. + $found = $null $envCpp = $env:DTU_CPPCHECK if ($envCpp) { if (-not (Test-Path $envCpp)) { Write-Host "::error::DTU_CPPCHECK is set but does not exist: $envCpp" + Write-Host "::error::fix the variable (full path to cppcheck.exe), or unset it to use auto-discovery" exit 1 } - $dir = Split-Path -Parent $envCpp - $env:Path = "$env:Path;$dir" - $dir | Out-File -FilePath $env:GITHUB_PATH -Append - Write-Host "using cppcheck from DTU_CPPCHECK: $envCpp" - & $envCpp --version + $found = $envCpp + Write-Host "using cppcheck from DTU_CPPCHECK: $found" } else { - $cpp = Get-Command cppcheck -ErrorAction SilentlyContinue - if (-not $cpp) { - Write-Host "::error::runner missing cppcheck (not on SYSTEM PATH) -- set a machine-level DTU_CPPCHECK, or add its directory to the machine PATH, then re-run" - exit 1 + $cmd = Get-Command cppcheck -ErrorAction SilentlyContinue + if ($cmd) { + $found = $cmd.Source + Write-Host "found cppcheck on PATH: $found" + } else { + $pf = $env:ProgramFiles + $pf86 = ${env:ProgramFiles(x86)} + if (-not $pf) { $pf = 'C:\Program Files' } + if (-not $pf86) { $pf86 = 'C:\Program Files (x86)' } + foreach ($cand in @( + (Join-Path $pf 'Cppcheck\cppcheck.exe'), + (Join-Path $pf86 'Cppcheck\cppcheck.exe'))) { + if (Test-Path $cand) { $found = $cand; break } + } } - Write-Host "found cppcheck: $($cpp.Source)" - cppcheck --version } + if (-not $found) { + Write-Host "::error::runner missing cppcheck (not on SYSTEM PATH, not in the standard install dir)" + Write-Host "::error::set a machine-level DTU_CPPCHECK (see scripts/ci_runner_bootstrap.ps1 -SetEnv) or add its directory to the machine PATH, then re-run" + exit 1 + } + $cppDir = Split-Path -Parent $found + $env:Path = "$env:Path;$cppDir" + # ASCII on purpose: Out-File's PS 5.1 default is UTF-16LE, and other + # steps append to this same file -- mixed encodings corrupt it. + $cppDir | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding ascii + & $found --version 2>&1 | Out-Host Write-Host "runner environment check passed" @@ -196,6 +199,34 @@ jobs: shell: powershell run: "& $env:PYTHON -s -m pre_commit install --install-hooks" + - name: 提交信息门禁(gitlint,commit-msg stage) + shell: powershell + env: + DTU_BASE_SHA: ${{ github.event.before }} + DTU_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + DTU_HEAD_SHA: ${{ github.sha }} + run: | + # Pure ASCII only (see file header). + # + # WHY THIS STEP EXISTS: gitlint is a `commit-msg` stage hook, and + # `pre-commit run --all-files` only runs the `pre-commit` stage -- so + # the commit-message rule was enforced locally and NEVER here, while + # the job still reported green (17 hooks defined, 16 reported). + # pre-commit has no notion of "the commits of this push", so the + # script feeds each commit message to the hook one at a time. + # + # Range: PR -> base..head; push -> event.before..sha, falling back to + # the newest commits when `before` is all-zero (new branch) or + # unreachable (force push). Merge commits are skipped (git writes them). + $base = $env:DTU_PR_BASE_SHA + if (-not $base) { $base = $env:DTU_BASE_SHA } + & './scripts/ci_gitlint_range.ps1' -BaseSha $base -HeadSha $env:DTU_HEAD_SHA ` + -MaxCommits 50 -PythonExe $env:PYTHON + if ($LASTEXITCODE -ne 0) { + Write-Host "::error::commit message gate failed (gitlint) -- see the per-commit output above" + exit $LASTEXITCODE + } + - name: 决定跳过哪些钩子(Keil 依赖) id: skip shell: powershell @@ -213,14 +244,14 @@ jobs: $map = Get-ChildItem -Path 'MDK-ARM' -Recurse -Filter '*.map' -ErrorAction SilentlyContinue if ($map) { Write-Host "found .map: $($map[0].FullName) -- mem-numbers runs normally" - "value=" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "sizegate=on" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "value=" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii + "sizegate=on" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii "> Volume gate (mem-numbers): RAN (.map present)" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 } else { Write-Host "::warning::no .map found (no Keil build) -- SKIPPING mem-numbers, so the Flash/RAM size gate does NOT run in CI. Size changes are NOT caught here; verify locally with 'python scripts/check_mem_numbers.py --scan' after a Keil build." - "value=mem-numbers" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "sizegate=off" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "value=mem-numbers" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii + "sizegate=off" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii "> :warning: Volume gate (mem-numbers): **NOT RUN** -- no `.map` (no Keil build)." | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 } @@ -339,10 +370,10 @@ jobs: } } if ($found) { - "value=yes" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "value=yes" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii } else { Write-Host "::warning::runner has no MSVC (VS C++ toolchain) -- skipping L0.5. Install VS BuildTools and re-run." - "value=no" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "value=no" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii } - name: 运行主机侧单测 @@ -350,28 +381,18 @@ jobs: if: steps.msvc.outputs.value == 'yes' shell: powershell run: | - # Resolve a working Python first (SYSTEM's PATH python may be unusable). - # Order per the DTU_* plan: DTU_PRECOMMIT_PYTHON (explicit; invalid = - # hard error) -> PATH python -> repo .venv. - $repoRoot = (Get-Location).Path - $py = $null - $envPy = $env:DTU_PRECOMMIT_PYTHON - if ($envPy) { - if (-not (Test-Path $envPy)) { - Write-Host "::error::DTU_PRECOMMIT_PYTHON is set but does not exist: $envPy" - exit 1 - } - $py = $envPy - } + # Resolve a working Python with the SAME shared probe as L0 (no + # -NeedPreCommit: these harnesses are stdlib-only). The old inline + # probe here had the same 2-candidate blind spot as L0 -- it only + # survived by luck, because the stdlib tests also run on the msys64 + # python that happens to be on SYSTEM's PATH. + $py = & './scripts/ci_find_python.ps1' -WriteEnv + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (-not $py) { - foreach ($c in @( - (Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source), - (Join-Path $repoRoot '.venv\Scripts\python.exe') - )) { - if ($c -and (Test-Path $c)) { $py = $c; break } - } + Write-Host "::error::ci_find_python.ps1 succeeded but returned no path" + exit 2 } - if (-not $py) { Write-Host "::error::no python found (set DTU_PRECOMMIT_PYTHON or fix the runner PATH)"; exit 1 } + $py = "$py".Trim() Write-Host "using python: $py" New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null @@ -423,8 +444,8 @@ jobs: Write-Host "::notice::$agg" Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### L0.5 host-side unit tests" -Encoding utf8 Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $agg -Encoding utf8 - "count=$runnerCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "agg=$agg" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "count=$runnerCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii + "agg=$agg" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii exit $fail - name: 归档 L0.5 日志(供下载/排障) diff --git a/AGENTS.md b/AGENTS.md index ceb4a5f9..bbb41677 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,10 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v - 提交信息:**单行中文标题 <100 字符**(gitlint),UTF-8 无 BOM + `git commit -F <文件>`(防终端转码破坏) - 代码规范 V1.0:小写下划线命名(`g_`/`s_`/`p_`/`a_` 前缀)、头文件保护宏 `XXX_H`、常量在右、文件头模块注释块 → `docs/代码规范/` - 全库扫描 `pre-commit run --all-files`;详见 `docs/代码规范/pre-commit使用说明.md` -- **CI(Gitea Actions,2026-10-01 T-6)**:`.gitea/workflows/ci.yml` 在 push/PR 跑 **L0 静态门禁 + L0.5 主机侧单测**(Windows runner);**不含 L1/L2/L3**(需真机+ST-LINK)、**刻意不跑 `@build`**(需 Keil 授权)、无 `.map` 时自适应跳过 `mem-numbers`。本地等价自查:`& '.venv\Scripts\python.exe' scripts/ci_local.py`(**runner 未就绪时这是 L0 的唯一保障**,不能因"已配 CI"放宽本地门禁)。详见 `docs/测试说明/测试开发标准流程.md` §9 +- **CI(Gitea Actions,2026-10-01 T-6)**:`.gitea/workflows/ci.yml` 在 push/PR 跑 **L0 静态门禁 + gitlint 提交信息门禁 + L0.5 主机侧单测**(Windows runner);**不含 L1/L2/L3**(需真机+ST-LINK)、**刻意不跑 `@build`**(需 Keil 授权)、无 `.map` 时自适应跳过 `mem-numbers`。本地等价自查:`& '.venv\Scripts\python.exe' scripts/ci_local.py`(**runner 未就绪时这是 L0 的唯一保障**,不能因"已配 CI"放宽本地门禁)。详见 `docs/测试说明/测试开发标准流程.md` §9 +- **`pre-commit run --all-files` 跑不到 `gitlint`**(2026-10-11 实测):该钩子声明 `stages: [commit-msg]`,而 `--all-files` 只跑 `pre-commit` stage → **17 个钩子定义、CI 只报 16 个、且表现为全绿**(提交信息门禁曾在 CI 完全空转)。现由 `scripts/ci_gitlint_range.ps1`(CI)与 `ci_local.py` 的 **L0b** 段(本地)逐条校验;**增删钩子后必须核对"定义数 == 日志条数 + gitlint"** +- **CI runner 必须有看门狗**(2026-10-11 实测):`gitea-runner` v4 的 `daemon` **无服务支持、首次 Declare 失败就直接退出进程**(Gitea 重启一次即停摆,run 16~21 共 12 个 job 零日志;叠加 `cancel-in-progress` 后症状是"任务凭空消失"而非失败)。体检 `& '.\scripts\ci_runner_bootstrap.ps1' -Status`;部署 `-SetEnv -StartNow`(看门狗 + 开机计划任务 + MACHINE 级 `DTU_PRECOMMIT_PYTHON`/`DTU_CPPCHECK`/`DTU_MSVC_CL`——runner 以 SYSTEM 跑,看不见用户级 PATH/site) +- **写 `GITHUB_ENV`/`GITHUB_PATH`/`GITHUB_OUTPUT` 必须显式 `-Encoding ascii`**(2026-10-11 实测):PS 5.1 的 `Out-File -Append` **默认 UTF-16LE**,会把 `42 00 42 00 3D 00` 这种带 NUL 的行追加进 runner 以 UTF-8 建的同一文件里 ## 平台约束 diff --git a/docs/测试说明/测试开发标准流程.md b/docs/测试说明/测试开发标准流程.md index 3ce0b467..8c1013d3 100644 --- a/docs/测试说明/测试开发标准流程.md +++ b/docs/测试说明/测试开发标准流程.md @@ -530,11 +530,20 @@ flowchart LR > → `python scripts/check_mem_numbers.py --scan`。详见 > [`../环境与部署/CI配置指南.md`](../环境与部署/CI配置指南.md) §0.0。 +> 🔴 **`gitlint` 属 `commit-msg` stage,`--all-files` 永远跑不到它(2026-10-11 审计)**: +> `.pre-commit-config.yaml` 定义 **17** 个钩子,而 CI 日志只报 **16** 个(`passed=15 skipped=1`)—— +> 缺的正是 gitlint(其钩子声明 `stages: [commit-msg]`,而 `pre-commit run --all-files` 只跑 +> `pre-commit` stage)。即"单行中文标题 <100 字符"这条门禁**本地有、CI 无**,且表现为**全绿** +> 而非失败,所以长期无人发现。现由 **`scripts/ci_gitlint_range.ps1`** 逐条校验本次 push/PR +> 引入的提交(PR 用 `base..head`;push 用 `event.before..sha`,`before` 全零/不可达时回退最近 +> N 条;跳过 merge 提交)。本地等价:`ci_local.py` 的 **L0b** 段。 + ### 9.2 两个 job | job | `runs-on` | 步骤要点 | |---|---|---| -| `l0-gates` | `windows` | checkout(`fetch-depth: 0`,gitlint/cubemx 需完整历史)→ 环境自检(Python / cppcheck / `python -m pre_commit`,缺则 `::error::` + exit 1)→ `pre_commit install --install-hooks` → **决定跳过哪些钩子**(探测 `.map`,写入 `GITHUB_OUTPUT`;无 `.map` 时 `::warning::` 声明体积门禁未跑)→ `pre-commit run --all-files --show-diff-on-failure`(`env: SKIP`,输出 `Tee` 到 `ci-artifacts/l0-pre-commit.log`)→ 打印 `::notice::L0: passed/failed/skipped` 汇总行 → 归档日志说明 | +| `l0-gates` | `windows` | checkout(`fetch-depth: 0`,gitlint/cubemx 需完整历史)→ 环境自检(**Python 探测走 `scripts/ci_find_python.ps1`**、cppcheck 按 `DTU_CPPCHECK` → PATH → 标准安装目录,缺则 `::error::` + exit 1)→ `pre_commit install --install-hooks` → **提交信息门禁 `scripts/ci_gitlint_range.ps1`**(见上方 🔴)→ **决定跳过哪些钩子**(探测 `.map`,写入 `GITHUB_OUTPUT`;无 `.map` 时 `::warning::` 声明体积门禁未跑)→ `pre-commit run --all-files --show-diff-on-failure`(`env: SKIP`,输出 `Tee` 到 `ci-artifacts/l0-pre-commit.log`)→ 打印 `::notice::L0: passed/failed/skipped` 汇总行 → 归档日志说明 | + | `l05-host-unit-tests` | `windows` | checkout → **探测 MSVC**(按 VS_ROOTS 找 `Hostx64\x64\cl.exe`)→ 遍历 `test/host/**/run_unit_test.py` 逐个跑(输出 `Tee` 到 `ci-artifacts/l05-<模块>.log`;解析各模块 `N/M PASSED` 汇总行 → 聚合 `::notice::L0.5: K runner(s), A/B assertions passed`;失败 `::error::` 并置退出码);**未探测到 MSVC 时打印醒目 WARNING 并降级跳过**(不硬失败,避免"没装 VS 的 runner 全红") | **结果可见性(S3,2026-10-02)**:两个 job 均写 `$GITHUB_STEP_SUMMARY`(job 运行页的 @@ -551,13 +560,17 @@ step-summary 达成。 CI 只在 push / PR 时跑,且需 runner 就绪。**提交前自查、或 runner 还没配好时**,用同一套判定在本地跑: ```powershell -& '.venv\Scripts\python.exe' scripts/ci_local.py # L0 + L0.5(与 CI 等价) -& '.venv\Scripts\python.exe' scripts/ci_local.py --l0-only # 只跑 L0 +& '.venv\Scripts\python.exe' scripts/ci_local.py # L0 + L0b + L0.5(与 CI 等价) +& '.venv\Scripts\python.exe' scripts/ci_local.py --l0-only # 只跑 L0(含 L0b 提交信息门禁) & '.venv\Scripts\python.exe' scripts/ci_local.py --l05-only # 只跑 L0.5 +& '.venv\Scripts\python.exe' scripts/ci_local.py --commits 20 # 提交信息校验最近 20 条 ``` -- **自动选用装了 pre-commit 的解释器**(优先级:`DTU_PRECOMMIT_PYTHON` 显式指定 → 当前解释器(重建 `.venv` 已自带 pre-commit)→ 仓库 `.venv` → PATH 的 `python`;显式值无效 → 硬报错并指明变量名,不静默回退); - 仍不可用时退出码 2 并给出修复提示。 +- **自动选用装了 pre-commit 的解释器**(优先级:`DTU_PRECOMMIT_PYTHON` 显式指定 → 当前解释器(重建 `.venv` 已自带 pre-commit)→ 仓库 `.venv` → PATH 的 `python` → **常见机器级安装位置**;显式值无效 → 硬报错并指明变量名,不静默回退); + 仍不可用时退出码 2 并列出**全部已尝试路径**。 + ⚠️ 该候选表与 CI 侧 **`scripts/ci_find_python.ps1`** 必须**同序同步**——2026-10-11 的 L0 全灭就是 + workflow 单方面把候选收窄成"PATH python + 仓库 `.venv`"(runner 上两者都是死路)导致的。 +- **L0b 提交信息门禁**:与 CI 的 `ci_gitlint_range.ps1` 同语义(`--all-files` 跑不到 commit-msg stage)。 - **`.map` 自适应与 CI 完全一致**:本地有 `.map` 就照常跑 `mem-numbers`,没有就跳过。 - 退出码:`0` 通过 / `1` 门禁或单测失败 / `2` 环境错误。 @@ -570,6 +583,8 @@ CI 只在 push / PR 时跑,且需 runner 就绪。**提交前自查、或 runn |---|---| | Gitea Actions | 实例开启 `[actions] ENABLED = true`(site admin 配置) | | Windows runner | 已注册且 **labels 含 `windows`**;装有 Python 3.x、cppcheck、pre-commit、MSVC(VS2019/2022 C++ 工具链 + Windows SDK) | +| **runner 必须有看门狗** | `gitea-runner` v4 的 `daemon` **无服务支持、且首次 Declare 失败就直接退出进程** → Gitea 重启一次,CI 就静默停摆(2026-10-11 实测:run 16~21 共 12 个 job 零日志)。用 **`scripts/ci_runner_bootstrap.ps1`** 装"等 Gitea 就绪 + 崩溃自拉起"的看门狗 + 计划任务 | +| 机器级工具变量 | runner 以 **SYSTEM** 运行,看不见交互用户的 PATH/用户级 site。用 `ci_runner_bootstrap.ps1 -SetEnv` 写入 **MACHINE** 级 `DTU_PRECOMMIT_PYTHON` / `DTU_CPPCHECK` / `DTU_MSVC_CL` | | 缺 MSVC 时 | L0.5 降级为 WARNING 跳过(不硬失败),但**等于该层裸奔**,须尽快补装 | > 📖 **配置步骤 / 排障**(服务端开关、runner 注册命令、PATH 与 MSVC 路径要求、 diff --git a/docs/环境与部署/CI配置指南.md b/docs/环境与部署/CI配置指南.md index 2fa9db57..9fc05702 100644 --- a/docs/环境与部署/CI配置指南.md +++ b/docs/环境与部署/CI配置指南.md @@ -160,20 +160,54 @@ cache: enabled: true ``` -### 2.4 以服务方式常驻 +### 2.4 常驻方式:**必须用看门狗,不能只起 daemon**(2026-10-11 实测重写) + +> 🔴 **`daemon install` 在 v4 已不存在**:本机 runner 是 `gitea-runner v4.0.1`,命令集只有 +> `bug-report / cache-server / config / daemon / exec / register`,**没有任何 Windows 服务 +> 管理代码**(`daemon` 是纯控制台程序,无法向 SCM 回报 `SERVICE_RUNNING`)。 +> 旧版本文写的 `act_runner.exe daemon install` 在本机**不可用**。 + +> 🔴🔴 更关键的坑:**`daemon` 在首次 Declare 失败时直接退出进程**(不重试)。 +> runner 日志(`D:\Docker\act_runner\log\runner-stdout.log`)实测: +> ``` +> level=error msg="fail to invoke Declare" error="unavailable: +> dial tcp 127.0.0.1:3001: connectex: ... actively refused it." +> Error: unavailable: dial tcp 127.0.0.1:3001 ... +> ``` +> 之后**没有**任何重试记录——进程已经没了。于是 **Gitea 重启一次 / 机器重启一次, +> CI 就静默停摆**:2026-10-08 起 runner 掉线,run **16~21 共 12 个 job 一行日志都没产生**; +> 又因 `ci.yml` 的 `concurrency.cancel-in-progress: true`,队列里的 run 会被**下一次 push 直接取消** +> (16~20 = `cancelled`、21 = 长期 `queued`)——症状看起来是"任务凭空消失",而不是"任务失败", +> 极难从 UI 反推根因。 + +**正确做法:一条命令装好"等 Gitea 就绪 + 崩溃自拉起 + 开机自启"**(管理员权限,仓库根目录): ```powershell -# 注册为 Windows 服务(管理员权限) -.\act_runner.exe daemon install -.\act_runner.exe daemon start - -# 或前台调试(先看日志通不通,推荐首次这样跑) -.\act_runner.exe daemon +powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv -StartNow ``` +它做三件事(细节见脚本头注释): + +| 动作 | 内容 | +|---|---| +| ① 机器级工具变量 | 按 CI 同一套探测写 **MACHINE** 级 `DTU_PRECOMMIT_PYTHON` / `DTU_CPPCHECK` / `DTU_MSVC_CL`(已存在则跳过,`-Force` 才覆盖) | +| ② 看门狗启动器 | 生成 `D:\Docker\act_runner\run_runner_watchdog.ps1`:先轮询 `/api/v1/version` 直到 Gitea 应答,再起 `daemon`;daemon 退出 → 记日志 → 10s 后重拉,**永不放弃** | +| ③ 计划任务 | `GiteaActionsRunner`:开机 +1 分钟触发、以 **SYSTEM** + 最高权限运行、**运行时限=不限**、失败每 1 分钟重启(×999)、不允许多实例 | + +随时体检(只读,不改任何东西): + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -Status +``` + +输出会告诉你:计划任务是否存在、daemon 是否在跑、Gitea 是否可达、三个机器级变量是否已设。 + 注册成功后,回到 **仓库 → Settings → Actions → Runners**,应看到 `win-stm32-ci` 状态为 **Idle / 绿色**。 +> ⚠️ **`.runner` 里的 `address` 是 runner 实际连接的地址**(本机 `http://127.0.0.1:3001`), +> 与 `ROOT_URL`(`http://gitea.bitnasdaq.vip/`)不是一回事;bootstrap 会以 `.runner` 为准。 + --- ## 3. Runner 软件环境:装什么 @@ -189,10 +223,35 @@ runner 是一台干净的 Windows,**下面这些必须装**,否则 job 会 | Keil / ST-LINK | ⛔ 不需要 | CI 刻意不跑 `@build` | — | | 开发板 / 串口 | ⛔ 不需要 | L1/L2/L3 不上 CI | — | -### 3.1 关键:PATH 必须对 +### 3.1 关键:变量必须对 **SYSTEM** 可见(不是"对你可见") -CI 的环境自检用的是 `Get-Command python` / `cppcheck`,**依赖 PATH**。 -装完后**新开一个终端**(或重启 runner 服务)让 PATH 生效,然后自检: +runner 以 **SYSTEM 服务/计划任务**运行,它看到的 PATH 与 site-packages **和你登录时完全不同**: + +| 你以为 | SYSTEM 实际看到 | 后果 | +|---|---|---| +| `python` 在 PATH 里 | PATH 上是 `C:\msys64\ucrt64\bin\python.exe`,**没有 pre-commit** | L0 环境自检失败 | +| `pip install --user pre-commit` 装好了 | **用户级 site 对 SYSTEM 完全不可见**(换任何 python 路径都没用) | 同上 | +| 仓库里有 `.venv` | `.venv` 已 `.gitignore`,runner 全新工作区**根本没有** | 同上 | +| `cppcheck` 在 PATH 里 | 机器 PATH 无 `C:\Program Files\Cppcheck` | L0 环境自检失败 | + +CI 侧的探测统一走 **`scripts/ci_find_python.ps1`**(两个 job 共用、可离线自测): + +``` +DTU_PRECOMMIT_PYTHON(显式;设了就必须有效,不静默回退) + -> PATH python -> 仓库 .venv -> 常见机器级安装(Program Files\Python3xx、 + %LOCALAPPDATA%\Programs\Python\Python3xx、%ProgramData%\anaconda3|miniconda3) +``` + +cppcheck 同理:`DTU_CPPCHECK` → PATH → `%ProgramFiles%\Cppcheck\cppcheck.exe` +(及 `%ProgramFiles(x86)%\Cppcheck\cppcheck.exe`)。 + +**推荐做法**:跑一次 bootstrap,把机器级变量固化下来(探测逻辑与 CI 完全一致): + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv +``` + +手工自检(三条都必须对 SYSTEM 成立,而不只是对你的终端成立): ```powershell python --version # 期望 Python 3.x @@ -200,6 +259,9 @@ cppcheck --version # 期望 Cppcheck 2.21.0 或更高 python -m pre_commit --version ``` +> ⚠️ 改了机器级变量后**必须重启 runner**(计划任务:`Stop-ScheduledTask` + `Start-ScheduledTask`) +> ——环境变量只在**进程启动时**读取,daemon 不重启就吃不到新值。 + ### 3.2 MSVC 的路径要求(探测逻辑) `l05-host-unit-tests` 的 MSVC 探测优先读机器级 `DTU_MSVC_CL`(x64 `cl.exe` 完整路径; @@ -316,13 +378,15 @@ github.com 会在 checkout 步骤卡住/失败。三种处置: | # | 检查 | 期望 | |---|---|---| | 1 | 仓库顶部有 **Actions** 标签页 | 服务端开关已开 | -| 2 | Settings → Actions → Runners 里 runner 为 **Idle** | runner 注册成功 | -| 3 | runner 上 `python --version` / `cppcheck --version` / `python -m pre_commit --version` | 三条全通 | -| 4 | `git push` 一次(或页面 Run workflow) | Actions 页出现运行中任务 | -| 5 | `l0-gates` 日志 | `pre-commit ... Passed`;`::notice::L0: pre-commit hooks -- passed=.. failed=0 skipped=..`;无 `.map` 时见 `::warning::` 体积门禁未跑 | -| 6 | `l05-host-unit-tests` 日志 | `::notice::L0.5: 2 runner(s), 139/139 assertions passed, fail=0` | -| 7 | job 运行页的 **Summary** 区块 | 出现 "L0 static gates" / "L0.5 host-side unit tests" 小标题与汇总行 | -| 8 | 提交记录旁出现 ✅ | CI 全绿 | +| 2 | Settings → Actions → Runners 里 runner 为 **Idle** | runner 注册成功且**在线**(`offline` = 后面全都不会跑,先做第 3 步) | +| 3 | `scripts\ci_runner_bootstrap.ps1 -Status` | 计划任务已注册 / daemon 在跑 / Gitea 可达 / 三个 `DTU_*` 已设——**四项全绿** | +| 4 | runner 上 `python --version` / `cppcheck --version` / `python -m pre_commit --version` | 三条全通(且对 SYSTEM 成立,见 §3.1) | +| 5 | `git push` 一次(或页面 Run workflow) | Actions 页出现运行中任务 | +| 6 | `l0-gates` 日志 | `using python (…)` + `runner environment check passed`;`::notice::L0: pre-commit hooks -- passed=.. failed=0 skipped=..`;无 `.map` 时见 `::warning::` 体积门禁未跑 | +| 7 | `l0-gates` 的 **提交信息门禁** 步骤 | `::notice::gitlint: N commit message(s) checked, 0 failed`(**这一行以前根本不存在**,见 §6) | +| 8 | `l05-host-unit-tests` 日志 | `::notice::L0.5: 3 runner(s), 278/278 assertions passed, fail=0` | +| 9 | job 运行页的 **Summary** 区块 | 出现 "L0 static gates" / "Commit message gate (gitlint)" / "L0.5 host-side unit tests" 小标题与汇总行 | +| 10 | 提交记录旁出现 ✅ | CI 全绿 | **本地先自查**(与 CI 同一套判定,避免推上去才发现问题): @@ -336,6 +400,11 @@ github.com 会在 checkout 步骤卡住/失败。三种处置: | 现象 | 根因 | 处置 | |---|---|---| +| 运行状态是 **`cancelled`** 且**一行日志都没有** | runner 掉线时 run 只在队列里等,**下一次 push 触发 `cancel-in-progress` 把它们取消**——所以表现为"任务凭空消失"而不是"任务失败" | 先按下一行修 runner;被取消的 run 可在页面 Re-run,或重新 push | +| Runners 页 runner 显示 **`offline`** | daemon 进程不在:`gitea-runner` v4 的 `daemon` **首次 Declare 失败就直接退出**(Gitea 重启/机器重启后无人拉起) | §2.4 用 `ci_runner_bootstrap.ps1` 装看门狗 + 计划任务;`-Status` 复查 | +| `l0-gates` 报 `no Python with a SYSTEM-visible pre-commit` | runner 是 SYSTEM:PATH 上的 python 无 pre-commit、用户级 site 不可见、`.venv` 未入库(§3.1 三重坑) | `ci_runner_bootstrap.ps1 -SetEnv` 写机器级 `DTU_PRECOMMIT_PYTHON`,或把 pre-commit 装进机器级解释器;**改完必须重启 runner** | +| `l0-gates` 报 `runner missing cppcheck` | 机器 PATH 无 Cppcheck 且无机器级 `DTU_CPPCHECK` | `-SetEnv`(会自动发现 `C:\Program Files\Cppcheck`)或装 `Cppcheck.Cppcheck` | +| **钩子条数对不上**(定义 17、CI 只报 16) | `gitlint` 声明 `stages: [commit-msg]`,`pre-commit run --all-files` **跑不到它**,且表现为全绿——静默空转 | 已由 `scripts/ci_gitlint_range.ps1` 单独校验;**以后增删钩子必须核对"定义数 == 日志条数 + gitlint"** | | push 后 Actions 页**空白/无任务** | ① 服务端开关未开 ② workflow 没 push 到远程 ③ 只改了 `docs/**`(被 `paths-ignore` 滤掉) | 逐条核对 §1 / §4.1 / §4.2 | | 任务一直 **Waiting / 排队** | 没有 label 匹配 `windows` 的 runner | 检查 `--labels "windows:host"`;Settings→Runners 是否 Idle | | `l0-gates` 报 `::error::runner 缺少:Python, cppcheck` | runner 没装或不在 PATH | 装完后**重启 runner 服务**(新进程才吃到新 PATH) | @@ -353,9 +422,17 @@ github.com 会在 checkout 步骤卡住/失败。三种处置: ## 7. 日常维护 -- **升级 runner**:停服务 → 换 `act_runner.exe` → 起服务(注册信息保留在 `config.yaml`); -- **改了 `VS_ROOTS` / 工具链探测逻辑**:记得**同步改 workflow 里的探测根目录**(两处一致); +- **每周(或每次改完 Gitea/机器重启后)体检**:`scripts\ci_runner_bootstrap.ps1 -Status`—— + 计划任务 / daemon 进程 / Gitea 可达 / 三个机器级 `DTU_*`,四项全绿才算 CI 活着; +- **升级 runner**:`Stop-ScheduledTask GiteaActionsRunner` → 换 `gitea-runner.exe` → + `Start-ScheduledTask GiteaActionsRunner`(注册信息在 `.runner`,配置在 `config.yaml`); + 若换了目录/地址,重跑 `ci_runner_bootstrap.ps1` 重新生成看门狗与计划任务; +- **改了工具链探测逻辑**:Python 侧只有一处事实源 **`scripts/ci_find_python.ps1`** + (两个 job 共用),本地 `scripts/ci_local.py` 的候选表须与它**同序**; + MSVC 探测仍是 workflow 内联 + `test/host/common/unit_test_harness.py` **两处一致**; - **钩子集变化**(`.pre-commit-config.yaml` 增删钩子):CI 自动跟随,无需改 workflow; + 但**必须核对条数**——`pre-commit run --all-files` 只跑 `pre-commit` stage, + `commit-msg` stage 的钩子(gitlint)不在其中,靠 `ci_gitlint_range.ps1` 单独守(见 §6); - **`@build` / L1 / L2**:永远留在本地,不要往 CI 加(需 Keil 授权与真机); - **runner 长时间不可用**时:CI 只是"多一道网",**提交前的本地 `ci_local.py` 仍是 L0 的唯一保障**, 不能因"已经配了 CI"放宽(见 `docs/待处理内容.md` 的部署前置警示块)。 @@ -366,6 +443,8 @@ github.com 会在 checkout 步骤卡住/失败。三种处置: | 日期 | 变更 | |---|---| +| 2026-10-11 | **修"很多任务不执行"(runner 静默停摆)+ 补 gitlint 门禁**:① §2.4 重写——`gitea-runner` v4 的 `daemon` **无服务支持、首次 Declare 失败即退出进程**(日志实证),Gitea/机器重启一次 CI 就停摆,**run 16~21 共 12 个 job 零日志**(`cancelled`/`queued`,叠加 `cancel-in-progress` 后表现为"任务凭空消失"而非失败);新增 **`scripts/ci_runner_bootstrap.ps1`**(等 Gitea 就绪 + 崩溃自拉起的看门狗 + 开机计划任务 + `-SetEnv` 写机器级 `DTU_*` + `-Status` 只读体检)。② §3.1 重写——SYSTEM 可见性三重坑(PATH python 是 msys64 无 pre-commit / 用户级 site 对 SYSTEM 不可见 / `.venv` 已 gitignore)是 run 14~15 L0 全灭根因(`33a4aa4` 把候选收窄成这两个、又删了 cppcheck 目录兜底,而机器级 `DTU_*` 从未设置);探测收进 **`scripts/ci_find_python.ps1`**(两 job 共用、可离线自测、失败列出全部候选)并恢复机器级安装兜底。③ 新增 **gitlint 提交信息门禁**:钩子定义 **17** 个而 CI 只报 **16** 个,缺的正是声明 `stages: [commit-msg]` 的 gitlint(`--all-files` 跑不到,且表现为全绿 → 长期无人发现)→ **`scripts/ci_gitlint_range.ps1`** 逐条校验本次 push/PR 范围(消息文件须写在仓库内 + UTF-8 无 BOM)。④ `GITHUB_ENV/PATH/OUTPUT` 写入统一显式 `-Encoding ascii`(PS 5.1 `Out-File -Append` 默认 **UTF-16LE**,实测落盘 `42 00 42 00 42 00 3D 00 31 00`)。§5 清单 8→10 条、§6 排障表 +5 行、§7 维护项重写 | +|---|---| | 2026-10-02 | **S3 + S14**:新增 §0.0「CI 不覆盖的体积门禁」醒目声明;新增 §4.4「产物存档与结果汇总」(零依赖 `Tee-Object` + `$GITHUB_STEP_SUMMARY` + `::notice::` 汇总行,不用 `actions/upload-artifact`;记录 `$ErrorActionPreference='Stop'` 与 `2>&1 \| Tee-Object` 的陷阱及处置)。§5 验证清单(7→8 条)与 §6 排障表同步更新 | | 2026-10-01 | **修正 `DEFAULT_ACTIONS_URL` 说明**(原 §1.2 示例误导):新增 §1.2.1 明确它控制的是 **action 下载源**(默认 `github.com`;`self` = 从自己实例拉且需先镜像 action),**不是填自己的 Gitea 地址**;§4.3 同步修正(原文误写"gitea.com 代理"),并补零依赖写法为内网首选。依据:Gitea 官方 FAQ + config-cheat-sheet | | 2026-10-01 | 初版:Gitea Actions 配置全流程(服务端开关 / Windows runner 注册 / runner 软件环境 / workflow 位置与触发 / 内网 action 拉取 / 端到端验证清单 / 故障排查 / 日常维护) | diff --git a/scripts/ci_find_python.ps1 b/scripts/ci_find_python.ps1 new file mode 100644 index 00000000..bbf85e29 --- /dev/null +++ b/scripts/ci_find_python.ps1 @@ -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. \.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= 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 diff --git a/scripts/ci_gitlint_range.ps1 b/scripts/ci_gitlint_range.ps1 new file mode 100644 index 00000000..13a45d6b --- /dev/null +++ b/scripts/ci_gitlint_range.ps1 @@ -0,0 +1,167 @@ +<# +.SYNOPSIS + Commit-message gate for CI: run the `gitlint` pre-commit hook over every + commit in a pushed / pull-request range. + +.DESCRIPTION + Why this file exists (2026-10-11 CI audit): + + .pre-commit-config.yaml defines 17 hooks, but CI reported 16 + (passed=15 failed=0 skipped=1). The missing one was `gitlint`: its hook + definition declares `stages: [commit-msg]`, while `pre-commit run + --all-files` only runs the `pre-commit` stage. So the commit-message rule + from AGENTS.md (single-line Chinese title < 100 chars) was enforced on + developer machines and NEVER in CI -- a silent gate gap, not a failure. + + pre-commit cannot check "the commits of this push" on its own, so this + script feeds each commit message to the hook through + `--hook-stage commit-msg --commit-msg-filename `, one commit at a + time, and aggregates the result. + + Message files are written INSIDE the workspace (under ci-artifacts/, which + is gitignored) for two hard reasons: + * pre-commit rejects a message path on another drive than the repo + ("path is on mount 'C:', start on mount 'D:'" -- measured here); + * they must be UTF-8 WITHOUT BOM, because a BOM counts as a character of + the title and would distort the 100-char rule. + +.PARAMETER BaseSha + Exclusive lower bound. Empty, all-zero (branch creation / deletion), or an + unreachable SHA (force push) falls back to the newest -MaxCommits commits. + +.PARAMETER HeadSha + Inclusive upper bound. Defaults to HEAD. + +.PARAMETER MaxCommits + Upper bound on how many commits are checked, so a big range cannot run for + hours. Merge commits are skipped (their messages are generated by git, and + the local hook is what guards hand-written ones). + +.PARAMETER PythonExe + Interpreter that provides pre-commit. Defaults to $env:PYTHON, then to + scripts/ci_find_python.ps1 auto-discovery. + +.OUTPUTS + None. Diagnostics go to the host as GitHub workflow commands. + Exit codes: 0 = all messages valid, 1 = at least one invalid, 2 = setup error. + +.NOTES + Keep this file pure ASCII (PowerShell 5.1 decodes BOM-less files as CP936). +#> +param( + [string]$BaseSha = '', + [string]$HeadSha = 'HEAD', + [int]$MaxCommits = 50, + [string]$PythonExe = '' +) + +$ErrorActionPreference = 'Continue' + +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location -LiteralPath $repoRoot +try { + # git emits UTF-8 commit messages; PS 5.1 would otherwise decode them with + # the console code page (CP936) and mangle every Chinese title. + $savedConsoleEncoding = [Console]::OutputEncoding + try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { } + + try { + # --- resolve the interpreter ---------------------------------------- + if ([string]::IsNullOrWhiteSpace($PythonExe)) { $PythonExe = $env:PYTHON } + if ([string]::IsNullOrWhiteSpace($PythonExe)) { + $PythonExe = & (Join-Path $PSScriptRoot 'ci_find_python.ps1') -NeedPreCommit + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($PythonExe)) { + Write-Host '::error::gitlint: no Python with pre-commit available' + exit 2 + } + } + $PythonExe = "$PythonExe".Trim() + Write-Host "gitlint: using python $PythonExe" + + # --- resolve the commit range --------------------------------------- + $range = '' + $cleanBase = "$BaseSha".Trim() + if ($cleanBase -match '^[0-9a-fA-F]{40}$' -and $cleanBase -notmatch '^0+$') { + $probe = "$cleanBase^{commit}" + git cat-file -e $probe 2>$null + if ($LASTEXITCODE -eq 0) { $range = "$cleanBase..$HeadSha" } + else { Write-Host "::warning::gitlint: base $cleanBase is not reachable in this clone -- falling back to the newest $MaxCommits commit(s)" } + } + + $savedPref = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + if ($range) { + $shas = git log --no-merges --format=%H $range 2>$null + } + else { + $shas = git log --no-merges --format=%H -n $MaxCommits $HeadSha 2>$null + } + $ErrorActionPreference = $savedPref + + $shas = @($shas | Where-Object { $_ -and "$_".Trim() }) + if ($shas.Count -eq 0) { + Write-Host '::notice::gitlint: no non-merge commits in range -- nothing to check' + exit 0 + } + if ($shas.Count -gt $MaxCommits) { + Write-Host "::warning::gitlint: range has $($shas.Count) commits, checking the newest $MaxCommits" + $shas = $shas[0..($MaxCommits - 1)] + } + + # --- message files: inside the workspace, UTF-8 without BOM --------- + $msgDir = Join-Path $repoRoot 'ci-artifacts\gitlint' + New-Item -ItemType Directory -Force -Path $msgDir | Out-Null + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + + $checked = 0 + $failed = 0 + foreach ($sha in $shas) { + $sha = "$sha".Trim() + $checked++ + + $savedPref = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $body = git log -1 --format=%B $sha 2>$null + $ErrorActionPreference = $savedPref + $titleLine = (git log -1 --format=%s $sha 2>$null | Select-Object -First 1) + + $msgPath = Join-Path $msgDir "$sha.txt" + $text = (($body | Out-String) -replace "`r`n", "`n").TrimEnd("`n") + [System.IO.File]::WriteAllText($msgPath, $text, $utf8NoBom) + + Write-Host "----------------------------------------" + Write-Host "commit $sha title: $titleLine" + + $savedPref = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + & $PythonExe -s -m pre_commit run --hook-stage commit-msg ` + --commit-msg-filename $msgPath gitlint 2>&1 + $rc = $LASTEXITCODE + $ErrorActionPreference = $savedPref + + if ($rc -ne 0) { + $failed++ + Write-Host "::error::gitlint: commit $($sha.Substring(0, 8)) rejected -- `"$titleLine`"" + } + } + + $summary = "gitlint: $checked commit message(s) checked, $failed failed" + Write-Host "::notice::$summary" + if ($env:GITHUB_STEP_SUMMARY) { + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value '### Commit message gate (gitlint)' -Encoding utf8 + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary -Encoding utf8 + } + + if ($failed -gt 0) { + Write-Host '::error::commit message gate failed -- rule: single-line Chinese title, < 100 chars (see docs/ pre-commit usage guide + .gitlint)' + exit 1 + } + exit 0 + } + finally { + try { [Console]::OutputEncoding = $savedConsoleEncoding } catch { } + } +} +finally { + Pop-Location +} diff --git a/scripts/ci_local.py b/scripts/ci_local.py index 63bbe073..cfc7a42a 100644 --- a/scripts/ci_local.py +++ b/scripts/ci_local.py @@ -8,9 +8,15 @@ 跑同一套判定,命令与退出码与 CI 一一对应。 覆盖范围(与 CI 完全一致): - L0 静态门禁 = `pre-commit run --all-files` + L0 静态门禁 = `pre-commit run --all-files` + 提交信息门禁(gitlint) L0.5 主机侧纯逻辑单测 = `test/host/**/run_unit_test.py` + ⚠️ gitlint 必须**单独跑**(2026-10-11 审计):该钩子声明 `stages: [commit-msg]`, + 而 `pre-commit run --all-files` 只跑 pre-commit stage —— 它既不在 --all-files 里, + CI 也一度漏掉(17 个钩子只报 16 个,且表现为"全绿")。本脚本与 CI 的 + `scripts/ci_gitlint_range.ps1` 同语义:逐条把最近 N 条非 merge 提交的 + message 喂给钩子。 + 刻意**不含**(与 CI 同理由,见 workflow 文件头): - `@build`(需 Keil 授权;CI 也不跑,真编译是开发者本地红线) - `mem-numbers` 钩子(事实源 `.map` 是 Keil 产物——**本地若存在 .map 就照常跑**, @@ -23,6 +29,7 @@ 汇总行(S3,2026-10-02,对齐 CI 的 ::notice::): [SUMMARY] L0: pre-commit hooks -- passed=N failed=M skipped=K + [SUMMARY] gitlint: N commit message(s) checked, M failed [SUMMARY] L0.5: K runner(s), A/B assertions passed, fail=F 用法(仓库根目录): @@ -30,6 +37,7 @@ & '.venv\\Scripts\\python.exe' scripts/ci_local.py --l0-only & '.venv\\Scripts\\python.exe' scripts/ci_local.py --l05-only & '.venv\\Scripts\\python.exe' scripts/ci_local.py --keep-going # 失败也继续跑完 + & '.venv\\Scripts\\python.exe' scripts/ci_local.py --commits 20 # 提交信息校验条数 设计依据:`.gitea/workflows/ci.yml`;docs/测试说明/测试开发标准流程.md §9 创建日期:2026-10-01 @@ -46,6 +54,30 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent +def _global_python_candidates(): + """常见**机器级** Python 安装位置(必须与 scripts/ci_find_python.ps1 同序)。 + + 两处探测一旦不同步,就会出现"本地能跑、CI 挂"的老毛病:2026-10-11 的 + L0 全灭就是 workflow 把候选收窄成 PATH python + 仓库 .venv(runner 上两者 + 都是死路),而真正装了 pre-commit 的机器级解释器(如 + C:\\ProgramData\\anaconda3\\python.exe)不再被探测。 + """ + out = [] + pf = os.environ.get("ProgramFiles", r"C:\Program Files") + pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") + local = os.environ.get("LOCALAPPDATA") + pd = os.environ.get("ProgramData", r"C:\ProgramData") + # 新版本优先:新装的解释器通常才是想要的。 + for ver in ("Python313", "Python312", "Python311", "Python310"): + out.append(os.path.join(pf, ver, "python.exe")) + out.append(os.path.join(pf86, ver, "python.exe")) + if local: + out.append(os.path.join(local, "Programs", "Python", ver, "python.exe")) + for dist in ("anaconda3", "miniconda3"): + out.append(os.path.join(pd, dist, "python.exe")) + return out + + def _resolve_python(): r"""挑一个真正装了 pre-commit 的解释器。 @@ -56,6 +88,7 @@ def _resolve_python(): 2. 当前解释器(sys.executable) 3. 仓库 `.venv\Scripts\python.exe` 4. PATH 上的 `python` + 5. 常见机器级安装位置(见 _global_python_candidates) 找到即返回 (python_path, note);都找不到则返回 (当前解释器, None) 交由调用方报错。 """ explicit = os.environ.get("DTU_PRECOMMIT_PYTHON", "").strip() @@ -80,12 +113,14 @@ def _resolve_python(): sys.executable, str(REPO_ROOT / ".venv" / "Scripts" / "python.exe"), shutil.which("python"), - ] + ] + _global_python_candidates() seen = set() + tried = [] for py in candidates: if not py or py in seen or not os.path.isfile(py): continue seen.add(py) + tried.append(py) try: cp = subprocess.run([py, "-m", "pre_commit", "--version"], capture_output=True, timeout=60) @@ -94,6 +129,9 @@ def _resolve_python(): if cp.returncode == 0: note = None if py == sys.executable else "(自动选用,本解释器无 pre-commit)" return py, note + print("[WARN] 未找到装有 pre-commit 的解释器,已尝试:") + for py in tried: + print(" - {}".format(py)) return sys.executable, None @@ -170,6 +208,75 @@ def run_l0(): return rc +def run_gitlint(count): + """提交信息门禁:把最近 N 条非 merge 提交的 message 逐条喂给 gitlint 钩子。 + + 与 CI 的 scripts/ci_gitlint_range.ps1 同语义(gitlint 声明 + `stages: [commit-msg]`,`pre-commit run --all-files` 永远跑不到它)。 + 返回退出码(0/1/2)。 + """ + print("") + print("=" * 64) + print(" L0b 提交信息门禁(gitlint / commit-msg stage,最近 {} 条)".format(count)) + print("=" * 64) + + def _git(*args): + return subprocess.run(["git"] + list(args), cwd=str(REPO_ROOT), + capture_output=True, timeout=60) + + try: + out = _git("log", "--no-merges", "--format=%H", "-n", str(count), "HEAD") + except (OSError, subprocess.TimeoutExpired) as e: + print("[ERROR] 无法调用 git:{}".format(e)) + return 2 + if out.returncode != 0: + print("[ERROR] git log 失败:{}".format( + out.stderr.decode("utf-8", errors="replace").strip())) + return 2 + + shas = [s.strip() for s in out.stdout.decode("utf-8", errors="replace").splitlines() + if s.strip()] + if not shas: + print("[INFO] 无可校验提交 —— 跳过") + print("[SUMMARY] gitlint: 0 commit message(s) checked, 0 failed") + return 0 + + # 消息文件必须写在【仓库内】且【UTF-8 无 BOM】: + # - 跨盘符会被 pre-commit 直接拒绝(path is on mount 'C:', start on mount 'D:') + # - BOM 会占掉标题 1 个字符,扭曲 .gitlint 的 100 字符上限 + msg_dir = REPO_ROOT / "ci-artifacts" / "gitlint-local" + msg_dir.mkdir(parents=True, exist_ok=True) + + checked = 0 + failed = 0 + try: + for sha in shas: + checked += 1 + body = _git("log", "-1", "--format=%B", sha) + title = _git("log", "-1", "--format=%s", sha) + text = body.stdout.decode("utf-8", errors="replace").rstrip("\r\n") + title_line = title.stdout.decode("utf-8", errors="replace").strip() + + msg_path = msg_dir / (sha + ".txt") + msg_path.write_text(text, encoding="utf-8", newline="\n") + + print("") + print("--- {} {}".format(sha[:8], title_line)) + rc, _ = _run_capture([PY, "-m", "pre_commit", "run", + "--hook-stage", "commit-msg", + "--commit-msg-filename", str(msg_path), + "gitlint"]) + if rc != 0: + failed += 1 + print("[FAIL] gitlint 拒绝该提交信息:{}".format(title_line)) + finally: + shutil.rmtree(str(msg_dir), ignore_errors=True) + + print("[SUMMARY] gitlint: {} commit message(s) checked, {} failed".format( + checked, failed)) + return 1 if failed else 0 + + def run_l05(args): """L0.5:主机侧单测。返回退出码(0/1/2)。""" print("") @@ -235,6 +342,8 @@ def main(argv=None): g.add_argument("--l05-only", action="store_true", help="只跑 L0.5 主机侧单测") ap.add_argument("--keep-going", action="store_true", help="某步失败也继续跑后续步骤(默认首错即停)") + ap.add_argument("--commits", type=int, default=10, metavar="N", + help="提交信息门禁(gitlint)校验最近 N 条非 merge 提交(默认 10)") args = ap.parse_args(argv) print("=== 本地 CI(等价 .gitea/workflows/ci.yml)===") @@ -263,10 +372,15 @@ def main(argv=None): try: if not args.l05_only: rc = run_l0() - if rc != 0 and not args.keep_going: - print("") - print("[FAIL] L0 未通过 —— 中止(--keep-going 可继续跑 L0.5)") - return 1 + # 提交信息门禁独立于 --all-files(stage 不同),故单独跑一段; + # 即使 L0 已失败也照跑,一次看全所有违规项。 + rc_msg = run_gitlint(args.commits) + if rc != 0 or rc_msg != 0: + if not args.keep_going: + print("") + print("[FAIL] L0 未通过 —— 中止(--keep-going 可继续跑 L0.5)") + return 1 + rc = rc or rc_msg if not args.l0_only: rc05 = run_l05(args) rc = rc or rc05 diff --git a/scripts/ci_runner_bootstrap.ps1 b/scripts/ci_runner_bootstrap.ps1 new file mode 100644 index 00000000..8a1bc3fe --- /dev/null +++ b/scripts/ci_runner_bootstrap.ps1 @@ -0,0 +1,352 @@ +<# +.SYNOPSIS + Make the Gitea Actions runner on this machine survive restarts, and give it + a toolchain the SYSTEM service account can actually see. + +.DESCRIPTION + Why this file exists (2026-10-11 CI audit, the "many jobs never ran" bug): + + Six consecutive runs (16..21, i.e. 12 jobs) produced ZERO log lines. The + runner `win-stm32-ci` was simply offline, and two independent causes made + that permanent: + + 1. NO SUPERVISION. gitea-runner v4.0.1 `daemon` has no service support + (v4 removed `daemon install`), and it EXITS when the first Declare + call fails. The runner log shows exactly that after a Gitea restart: + + level=error msg="fail to invoke Declare" error="unavailable: + dial tcp 127.0.0.1:3001: connectex: ... actively refused it." + Error: unavailable: dial tcp 127.0.0.1:3001 ... + + ...and the process is gone. So one Gitea restart (or one reboot) + silently kills CI until a human notices days later. Queued runs then + get cancelled by the NEXT push, because ci.yml sets + `concurrency.cancel-in-progress: true` -- which is why the symptom + looks like "tasks vanish" rather than "tasks fail". + + 2. NO MACHINE-LEVEL TOOLCHAIN. The runner service runs as SYSTEM. + pre-commit / cppcheck were only reachable through the interactive + user's PATH, and no machine-level DTU_* variable was ever set, so + even a live runner died in the L0 environment check. + + This script installs the missing supervision (a watchdog launcher + a + scheduled task) and, with -SetEnv, writes the machine-level DTU_* variables + by discovering the tools the same way CI does. + + Run elevated, from the repo: + + powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv -StartNow + +.PARAMETER RunnerDir + Directory holding gitea-runner.exe / config.yaml / .runner. + +.PARAMETER GiteaUrl + Instance address the runner registered against (must match .runner's + "address"; the runner talks to it directly, not through the public URL). + +.PARAMETER TaskName + Scheduled-task name for the watchdog. + +.PARAMETER SetEnv + Discover and write MACHINE-level DTU_PRECOMMIT_PYTHON / DTU_CPPCHECK / + DTU_MSVC_CL. Existing values are preserved unless -Force. + +.PARAMETER StartNow + Start the task after registering it. + +.PARAMETER Status + Only report: task state, daemon process, Gitea reachability. + +.PARAMETER Force + With -SetEnv, overwrite existing machine-level values. + +.OUTPUTS + Progress lines. Exit codes: 0 = ok, 1 = precondition failed, 2 = not elevated. + +.NOTES + Keep this file pure ASCII (PowerShell 5.1 decodes BOM-less files as CP936). +#> +[CmdletBinding()] +param( + [string]$RunnerDir = 'D:\Docker\act_runner', + [string]$GiteaUrl = 'http://127.0.0.1:3001', + [string]$TaskName = 'GiteaActionsRunner', + [switch]$SetEnv, + [switch]$StartNow, + [switch]$Status, + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +function Info($m) { Write-Host "[INFO] $m" -ForegroundColor Cyan } +function Ok($m) { Write-Host "[ OK ] $m" -ForegroundColor Green } +function Warn($m) { Write-Host "[WARN] $m" -ForegroundColor Yellow } +function Fail($m) { Write-Host "[FAIL] $m" -ForegroundColor Red } + +function Test-Admin { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + (New-Object Security.Principal.WindowsPrincipal($id) + ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Test-Gitea([string]$url) { + try { + Invoke-WebRequest -Uri "$url/api/v1/version" -UseBasicParsing -TimeoutSec 10 | Out-Null + return $true + } + catch { return $false } +} + +function Get-MachineEnv([string]$name) { + $v = [Environment]::GetEnvironmentVariable($name, 'Machine') + if ([string]::IsNullOrWhiteSpace($v)) { return $null } + return $v +} + +function Set-MachineEnv([string]$name, [string]$value, [string]$label) { + $cur = Get-MachineEnv $name + if ($cur) { + if ($cur -ieq $value) { Ok "$label already set: $cur"; return } + if (-not $Force) { + Warn "$label already set to a different value: $cur (use -Force to overwrite)" + return + } + Warn "$label overwriting $cur -> $value" + } + [Environment]::SetEnvironmentVariable($name, $value, 'Machine') + Ok "$label set (MACHINE scope): $value" +} + +function Find-Cppcheck { + $pf = $env:ProgramFiles + $pf86 = ${env:ProgramFiles(x86)} + if ([string]::IsNullOrWhiteSpace($pf)) { $pf = 'C:\Program Files' } + if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' } + foreach ($c in @((Join-Path $pf 'Cppcheck\cppcheck.exe'), + (Join-Path $pf86 'Cppcheck\cppcheck.exe'))) { + if (Test-Path -LiteralPath $c) { return $c } + } + $cmd = Get-Command cppcheck -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + return $null +} + +function Find-MsvcCl { + $pf = $env:ProgramFiles + $pf86 = ${env:ProgramFiles(x86)} + if ([string]::IsNullOrWhiteSpace($pf)) { $pf = 'C:\Program Files' } + if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' } + $roots = @() + foreach ($year in @('2019', '2022', '2026', '18')) { + foreach ($edition in @('BuildTools', 'Community', 'Professional', 'Enterprise')) { + $roots += (Join-Path $pf86 "Microsoft Visual Studio\$year\$edition") + $roots += (Join-Path $pf "Microsoft Visual Studio\$year\$edition") + } + } + foreach ($r in $roots) { + if (-not (Test-Path -LiteralPath $r)) { continue } + $cl = Get-ChildItem -LiteralPath $r -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -like '*Hostx64\x64*' } | + Select-Object -First 1 + if ($cl) { return $cl.FullName } + } + return $null +} + +# --------------------------------------------------------------------------- +# -Status: read-only report +# --------------------------------------------------------------------------- +if ($Status) { + Info "task $TaskName" + $t = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($t) { Ok ("task state: {0}" -f $t.State) } else { Warn 'task not registered' } + + $proc = Get-Process | Where-Object { $_.ProcessName -match 'gitea-runner|act_runner' } + if ($proc) { Ok ("daemon running: pid(s) " + ($proc.Id -join ',')) } else { Warn 'daemon not running' } + + if (Test-Gitea $GiteaUrl) { Ok "Gitea reachable at $GiteaUrl" } + else { Fail "Gitea NOT reachable at $GiteaUrl" } + + foreach ($n in @('DTU_PRECOMMIT_PYTHON', 'DTU_CPPCHECK', 'DTU_MSVC_CL')) { + $v = Get-MachineEnv $n + if ($v) { Ok ("MACHINE {0} = {1}" -f $n, $v) } + else { Warn "MACHINE $n not set (CI auto-discovery still applies; see scripts/ci_find_python.ps1)" } + } + exit 0 +} + +# --------------------------------------------------------------------------- +# Preconditions +# --------------------------------------------------------------------------- +if (-not (Test-Admin)) { + Fail 'not elevated -- rerun from an elevated PowerShell (this writes a scheduled task and machine env vars)' + exit 2 +} + +$exe = Join-Path $RunnerDir 'gitea-runner.exe' +$cfg = Join-Path $RunnerDir 'config.yaml' +$regFile = Join-Path $RunnerDir '.runner' +$launcher = Join-Path $RunnerDir 'run_runner_watchdog.ps1' + +foreach ($f in @($exe, $cfg)) { + if (-not (Test-Path -LiteralPath $f)) { Fail "missing file: $f"; exit 1 } +} +if (-not (Test-Path -LiteralPath $regFile)) { + Fail "not registered: $regFile is missing" + Info 'register first, e.g.:' + Write-Host " & '$exe' register --no-interactive --instance '$GiteaUrl' --token --name win-stm32-ci --labels 'windows:host'" + exit 1 +} + +# The daemon must talk to the address baked into .runner, not the public URL. +$registeredAddress = $null +try { + $registeredAddress = (Get-Content -LiteralPath $regFile -Raw | ConvertFrom-Json).address +} +catch { Warn "cannot parse $regFile : $_" } +if ($registeredAddress -and $registeredAddress.TrimEnd('/') -ne $GiteaUrl.TrimEnd('/')) { + Warn ".runner address is $registeredAddress but -GiteaUrl is $GiteaUrl -- using the .runner value for the readiness probe" + $GiteaUrl = $registeredAddress +} + +# --------------------------------------------------------------------------- +# 1. machine-level toolchain (SYSTEM cannot see the interactive user's PATH) +# --------------------------------------------------------------------------- +if ($SetEnv) { + Info 'discovering toolchain for MACHINE scope ...' + + $findPy = Join-Path $PSScriptRoot 'ci_find_python.ps1' + if (Test-Path -LiteralPath $findPy) { + $py = & $findPy -NeedPreCommit + if ($LASTEXITCODE -eq 0 -and $py) { + Set-MachineEnv 'DTU_PRECOMMIT_PYTHON' ("$py".Trim()) 'DTU_PRECOMMIT_PYTHON' + } + else { + Warn 'no interpreter with pre-commit found -- install pre-commit into a machine-wide interpreter first' + } + } + else { Warn "not found: $findPy (skipping DTU_PRECOMMIT_PYTHON)" } + + $cpp = Find-Cppcheck + if ($cpp) { Set-MachineEnv 'DTU_CPPCHECK' $cpp 'DTU_CPPCHECK' } + else { Warn 'cppcheck not found (install Cppcheck.Cppcheck, or set DTU_CPPCHECK manually)' } + + $cl = Find-MsvcCl + if ($cl) { Set-MachineEnv 'DTU_MSVC_CL' $cl 'DTU_MSVC_CL' } + else { Warn 'MSVC cl.exe (Hostx64\x64) not found -- L0.5 will degrade to "skipped" without it' } + + Info 'machine env vars are read when a PROCESS STARTS: the daemon must be restarted below' +} + +# --------------------------------------------------------------------------- +# 2. watchdog launcher (the actual fix for "daemon exits and nobody notices") +# --------------------------------------------------------------------------- +Info "writing watchdog launcher: $launcher" + +$template = @' +# AUTO-GENERATED by scripts/ci_runner_bootstrap.ps1 -- do not edit by hand. +# +# gitea-runner `daemon` exits when its first Declare call fails (no retry, no +# service support in v4), so a plain scheduled task is NOT enough: a Gitea +# restart would take CI down until a human noticed. This loop +# 1) waits until Gitea answers, then +# 2) runs the daemon, and +# 3) restarts it after a short pause whenever it exits. +# Keep this file pure ASCII (PowerShell 5.1 decodes BOM-less files as CP936). +$ErrorActionPreference = 'Continue' + +$runnerDir = '__RUNNER_DIR__' +$runnerExe = Join-Path $runnerDir 'gitea-runner.exe' +$runnerCfg = Join-Path $runnerDir 'config.yaml' +$logDir = Join-Path $runnerDir 'log' +$log = Join-Path $logDir 'runner-stdout.log' +$probeUrl = '__GITEA_URL__/api/v1/version' +$maxWaitSec = 1800 + +if (-not (Test-Path -LiteralPath $logDir)) { + New-Item -ItemType Directory -Force -Path $logDir | Out-Null +} + +function Log($m) { + $line = '{0} [watchdog] {1}' -f (Get-Date -Format 'o'), $m + Add-Content -LiteralPath $log -Value $line -Encoding ascii +} + +while ($true) { + $waited = 0 + while ($true) { + $ok = $false + try { + Invoke-WebRequest -Uri $probeUrl -UseBasicParsing -TimeoutSec 10 | Out-Null + $ok = $true + } + catch { $ok = $false } + if ($ok) { break } + if ($waited -eq 0) { Log "waiting for Gitea at $probeUrl" } + Start-Sleep -Seconds 15 + $waited += 15 + if ($waited -ge $maxWaitSec) { + Log 'Gitea still not answering after 30m -- starting anyway so the real error lands in the log' + break + } + } + + Log 'starting daemon' + & $runnerExe daemon --config $runnerCfg *>> $log + Log ("daemon exited, exit code {0}; restarting in 10s" -f $LASTEXITCODE) + Start-Sleep -Seconds 10 +} +'@ + +$content = $template.Replace('__RUNNER_DIR__', $RunnerDir).Replace('__GITEA_URL__', $GiteaUrl.TrimEnd('/')) +$utf8NoBom = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllText($launcher, $content, $utf8NoBom) +Ok 'launcher written' + +# --------------------------------------------------------------------------- +# 3. scheduled task: start at boot, run as SYSTEM, restart if it dies +# --------------------------------------------------------------------------- +$existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue +if ($existing) { + Info "task $TaskName exists -- unregistering to re-register with the current settings" + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false +} + +$action = New-ScheduledTaskAction ` + -Execute 'powershell.exe' ` + -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$launcher`"" ` + -WorkingDirectory $RunnerDir + +# -AtStartup needs the "System" account; a boot trigger plus StartWhenAvailable +# also covers "machine was off when the task was created". +$trigger = New-ScheduledTaskTrigger -AtStartup +$trigger.Delay = 'PT1M' +$trigger.StartWhenAvailable = $true + +$settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit ([TimeSpan]::Zero) ` + -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ` + -MultipleInstances IgnoreNew + +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` + -Settings $settings -User 'SYSTEM' -RunLevel Highest ` + -Description 'Gitea Actions runner watchdog (STM32F4-Base CI). Supervises gitea-runner daemon: waits for Gitea, restarts the daemon when it exits. Generated by scripts/ci_runner_bootstrap.ps1.' | + Out-Null +Ok "scheduled task registered: $TaskName (at boot +1m, as SYSTEM, unlimited runtime, restart x999/1m)" + +# --------------------------------------------------------------------------- +# 4. start it +# --------------------------------------------------------------------------- +if ($StartNow) { + if (-not (Test-Gitea $GiteaUrl)) { + Warn "Gitea is not answering at $GiteaUrl right now -- the watchdog will keep waiting" + } + Start-ScheduledTask -TaskName $TaskName + Ok "task started; watch: Get-Content '$(Join-Path $RunnerDir 'log\runner-stdout.log')' -Tail 20" + Info 'verify on the web UI: repo -> Settings -> Actions -> Runners -> win-stm32-ci should turn Idle' +} + +Ok 'done' +exit 0 From 0f0ea03ff4e4d8ccbf0453835a8407046400178c Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Mon, 12 Oct 2026 12:52:27 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dbootstrap=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=B4=A9=E6=BA=83=EF=BC=9AStartWhenAvailable=E5=B1=9E?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E8=AE=BE=E7=BD=AE=E8=80=8C=E9=9D=9E=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现场:用户以管理员跑 -SetEnv -StartNow 时脚本抛 SetValueInvocationException(触发器上没有 StartWhenAvailable 属性), 而它是在"已注销旧任务、尚未注册新任务"之间抛的,等于把 runner 任务删没了。 - StartWhenAvailable 改设到 settings(MSFT_TaskSettings 继承属性, CIM 属性列表里看不见但可直接赋值,实测 StartWhenAvailable=True) - 任务对象改由 New-WatchdogTaskPlan 统一构建,先构建、后注销旧任务 - Register-ScheduledTask 失败时显式告警"旧任务已删、CI 裸奔"并 exit 1 - 新增 -DryRun:免管理员即可全量校验 action/trigger/settings - -Status 增补上次运行时间与返回码,并提示"非管理员看不见任务" 同时更正根因:旧计划任务其实一直存在(非管理员会话看不见,先前误判为 "从未注册"),但它跑的是单次 run_runner.cmd——daemon 属"正常退出"而非 "失败",计划任务的失败重启根本接不到,必须靠看门狗循环重拉。 --- AGENTS.md | 2 +- docs/环境与部署/CI配置指南.md | 29 +++++++- scripts/ci_runner_bootstrap.ps1 | 117 +++++++++++++++++++++++++------- 3 files changed, 123 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bbb41677..a67b8d02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v - 全库扫描 `pre-commit run --all-files`;详见 `docs/代码规范/pre-commit使用说明.md` - **CI(Gitea Actions,2026-10-01 T-6)**:`.gitea/workflows/ci.yml` 在 push/PR 跑 **L0 静态门禁 + gitlint 提交信息门禁 + L0.5 主机侧单测**(Windows runner);**不含 L1/L2/L3**(需真机+ST-LINK)、**刻意不跑 `@build`**(需 Keil 授权)、无 `.map` 时自适应跳过 `mem-numbers`。本地等价自查:`& '.venv\Scripts\python.exe' scripts/ci_local.py`(**runner 未就绪时这是 L0 的唯一保障**,不能因"已配 CI"放宽本地门禁)。详见 `docs/测试说明/测试开发标准流程.md` §9 - **`pre-commit run --all-files` 跑不到 `gitlint`**(2026-10-11 实测):该钩子声明 `stages: [commit-msg]`,而 `--all-files` 只跑 `pre-commit` stage → **17 个钩子定义、CI 只报 16 个、且表现为全绿**(提交信息门禁曾在 CI 完全空转)。现由 `scripts/ci_gitlint_range.ps1`(CI)与 `ci_local.py` 的 **L0b** 段(本地)逐条校验;**增删钩子后必须核对"定义数 == 日志条数 + gitlint"** -- **CI runner 必须有看门狗**(2026-10-11 实测):`gitea-runner` v4 的 `daemon` **无服务支持、首次 Declare 失败就直接退出进程**(Gitea 重启一次即停摆,run 16~21 共 12 个 job 零日志;叠加 `cancel-in-progress` 后症状是"任务凭空消失"而非失败)。体检 `& '.\scripts\ci_runner_bootstrap.ps1' -Status`;部署 `-SetEnv -StartNow`(看门狗 + 开机计划任务 + MACHINE 级 `DTU_PRECOMMIT_PYTHON`/`DTU_CPPCHECK`/`DTU_MSVC_CL`——runner 以 SYSTEM 跑,看不见用户级 PATH/site) +- **CI runner 必须有看门狗**(2026-10-11 实测):`gitea-runner` v4 的 `daemon` **无服务支持、首次 Declare 失败就直接退出进程**(Gitea 重启一次即停摆,run 16~21 共 12 个 job 零日志;叠加 `cancel-in-progress` 后症状是"任务凭空消失"而非失败)。⚠️ **计划任务的"失败重启"救不了它**——daemon 是**正常退出**不是失败,旧任务跑单次 `daemon` 会"成功结束"后再也不拉,必须**看门狗循环重拉**。体检 `& '.\scripts\ci_runner_bootstrap.ps1' -Status`(**须管理员**:非管理员会话可能看不见该任务)、干跑 `-DryRun`(免管理员校验任务对象)、部署 `-SetEnv -StartNow`(看门狗 + 开机计划任务 + MACHINE 级 `DTU_PRECOMMIT_PYTHON`/`DTU_CPPCHECK`/`DTU_MSVC_CL`——runner 以 SYSTEM 跑,看不见用户级 PATH/site)。改该脚本时守住两条:`StartWhenAvailable` 属**任务设置**(触发器上没这属性,误设抛 `SetValueInvocationException`)、且必须**先构建对象后注销旧任务**(否则旧任务被删、新任务没建成) - **写 `GITHUB_ENV`/`GITHUB_PATH`/`GITHUB_OUTPUT` 必须显式 `-Encoding ascii`**(2026-10-11 实测):PS 5.1 的 `Out-File -Append` **默认 UTF-16LE**,会把 `42 00 42 00 3D 00` 这种带 NUL 的行追加进 runner 以 UTF-8 建的同一文件里 ## 平台约束 diff --git a/docs/环境与部署/CI配置指南.md b/docs/环境与部署/CI配置指南.md index 9fc05702..5cc08529 100644 --- a/docs/环境与部署/CI配置指南.md +++ b/docs/环境与部署/CI配置指南.md @@ -180,6 +180,14 @@ cache: > (16~20 = `cancelled`、21 = 长期 `queued`)——症状看起来是"任务凭空消失",而不是"任务失败", > 极难从 UI 反推根因。 +> 🔴 **为什么"已经有计划任务"仍然会死**(2026-10-11 实测,别以为注册过就安全): +> 旧任务跑的是 `run_runner.cmd`,里面是**单次** `gitea-runner.exe daemon ...`(无循环、无重启语义)。 +> 开机时 Gitea 往往还没起来 → daemon 首次 Declare 失败**立刻退出** → 任务"成功结束" → +> **计划任务认为一切正常,永远不会再拉**。日志里 10-09 18:50 连续三次 +> `Starting runner daemon` + 三次 Declare 失败,就是三次"起了就死、死了没人管"。 +> 所以必须换成**看门狗**(等就绪 + 循环重拉),单靠计划任务的"失败重启"救不了 +> ——daemon 是"正常退出",不是"失败"。 + **正确做法:一条命令装好"等 Gitea 就绪 + 崩溃自拉起 + 开机自启"**(管理员权限,仓库根目录): ```powershell @@ -194,13 +202,30 @@ powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv | ② 看门狗启动器 | 生成 `D:\Docker\act_runner\run_runner_watchdog.ps1`:先轮询 `/api/v1/version` 直到 Gitea 应答,再起 `daemon`;daemon 退出 → 记日志 → 10s 后重拉,**永不放弃** | | ③ 计划任务 | `GiteaActionsRunner`:开机 +1 分钟触发、以 **SYSTEM** + 最高权限运行、**运行时限=不限**、失败每 1 分钟重启(×999)、不允许多实例 | +**先干跑,再真装**(不需要管理员,把 action/trigger/settings 全构建一遍并打印,什么都不写): + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -DryRun +``` + 随时体检(只读,不改任何东西): ```powershell powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -Status ``` -输出会告诉你:计划任务是否存在、daemon 是否在跑、Gitea 是否可达、三个机器级变量是否已设。 +输出会告诉你:计划任务是否存在(含上次运行时间与返回码)、daemon 是否在跑、Gitea 是否可达、 +三个机器级变量是否已设。 + +> ⚠️ **`-Status` 要用管理员跑**:非管理员会话**不一定看得见** `GiteaActionsRunner` +> (本次排障就被这一点误导过一次,以为任务从未注册)。脚本在非管理员下会额外提示这一点。 + +> 🔴 **改这个脚本时守住两条**(都是踩出来的): +> ① `StartWhenAvailable` 是**任务设置**属性(`MSFT_TaskSettings`,被 `MSFT_TaskSettings3` 继承), +> **触发器上没有这个属性**——写 `$trigger.StartWhenAvailable` 会抛 `SetValueInvocationException` +> 中断脚本(实测,且 CIM 属性列表里看不到它,只能直接赋值); +> ② **必须"先构建全部对象、后注销旧任务"**——早期版本先 `Unregister` 再构建,一旦构建阶段抛异常, +> 旧任务已被删、新任务没建成,CI 直接裸奔。 注册成功后,回到 **仓库 → Settings → Actions → Runners**,应看到 `win-stm32-ci` 状态为 **Idle / 绿色**。 @@ -443,6 +468,8 @@ github.com 会在 checkout 步骤卡住/失败。三种处置: | 日期 | 变更 | |---|---| +| 2026-10-11 | **bootstrap 自身修复**:`StartWhenAvailable` 误设到**触发器**上(该属性属**任务设置**,触发器没有)→ 脚本在"已注销旧任务、尚未注册新任务"之间抛 `SetValueInvocationException`,把 runner 任务删没了。现改为①先构建 action/trigger/settings、**后**注销旧任务,②`Register-ScheduledTask` 失败时显式告警"旧任务已删、CI 裸奔",③新增 **`-DryRun`**(免管理员即可全量校验任务对象),④`-Status` 在非管理员下额外提示"看不见任务 ≠ 任务不存在"。同时更正根因:旧任务其实存在,但跑的是**单次** `daemon`——daemon 属"正常退出"而非"失败",计划任务的失败重启**救不到**它,必须用看门狗循环重拉 | +|---|---| | 2026-10-11 | **修"很多任务不执行"(runner 静默停摆)+ 补 gitlint 门禁**:① §2.4 重写——`gitea-runner` v4 的 `daemon` **无服务支持、首次 Declare 失败即退出进程**(日志实证),Gitea/机器重启一次 CI 就停摆,**run 16~21 共 12 个 job 零日志**(`cancelled`/`queued`,叠加 `cancel-in-progress` 后表现为"任务凭空消失"而非失败);新增 **`scripts/ci_runner_bootstrap.ps1`**(等 Gitea 就绪 + 崩溃自拉起的看门狗 + 开机计划任务 + `-SetEnv` 写机器级 `DTU_*` + `-Status` 只读体检)。② §3.1 重写——SYSTEM 可见性三重坑(PATH python 是 msys64 无 pre-commit / 用户级 site 对 SYSTEM 不可见 / `.venv` 已 gitignore)是 run 14~15 L0 全灭根因(`33a4aa4` 把候选收窄成这两个、又删了 cppcheck 目录兜底,而机器级 `DTU_*` 从未设置);探测收进 **`scripts/ci_find_python.ps1`**(两 job 共用、可离线自测、失败列出全部候选)并恢复机器级安装兜底。③ 新增 **gitlint 提交信息门禁**:钩子定义 **17** 个而 CI 只报 **16** 个,缺的正是声明 `stages: [commit-msg]` 的 gitlint(`--all-files` 跑不到,且表现为全绿 → 长期无人发现)→ **`scripts/ci_gitlint_range.ps1`** 逐条校验本次 push/PR 范围(消息文件须写在仓库内 + UTF-8 无 BOM)。④ `GITHUB_ENV/PATH/OUTPUT` 写入统一显式 `-Encoding ascii`(PS 5.1 `Out-File -Append` 默认 **UTF-16LE**,实测落盘 `42 00 42 00 42 00 3D 00 31 00`)。§5 清单 8→10 条、§6 排障表 +5 行、§7 维护项重写 | |---|---| | 2026-10-02 | **S3 + S14**:新增 §0.0「CI 不覆盖的体积门禁」醒目声明;新增 §4.4「产物存档与结果汇总」(零依赖 `Tee-Object` + `$GITHUB_STEP_SUMMARY` + `::notice::` 汇总行,不用 `actions/upload-artifact`;记录 `$ErrorActionPreference='Stop'` 与 `2>&1 \| Tee-Object` 的陷阱及处置)。§5 验证清单(7→8 条)与 §6 排障表同步更新 | diff --git a/scripts/ci_runner_bootstrap.ps1 b/scripts/ci_runner_bootstrap.ps1 index 8a1bc3fe..fa469051 100644 --- a/scripts/ci_runner_bootstrap.ps1 +++ b/scripts/ci_runner_bootstrap.ps1 @@ -57,6 +57,11 @@ .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. @@ -74,6 +79,7 @@ param( [switch]$SetEnv, [switch]$StartNow, [switch]$Status, + [switch]$DryRun, [switch]$Force ) @@ -154,13 +160,63 @@ function Find-MsvcCl { 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) } else { Warn 'task not registered' } + 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) { Ok ("daemon running: pid(s) " + ($proc.Id -join ',')) } else { Warn 'daemon not running' } @@ -179,8 +235,9 @@ if ($Status) { # --------------------------------------------------------------------------- # Preconditions # --------------------------------------------------------------------------- -if (-not (Test-Admin)) { +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 } @@ -210,6 +267,27 @@ if ($registeredAddress -and $registeredAddress.TrimEnd('/') -ne $GiteaUrl.TrimEn $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) # --------------------------------------------------------------------------- @@ -313,27 +391,20 @@ if ($existing) { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false } -$action = New-ScheduledTaskAction ` - -Execute 'powershell.exe' ` - -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$launcher`"" ` - -WorkingDirectory $RunnerDir - -# -AtStartup needs the "System" account; a boot trigger plus StartWhenAvailable -# also covers "machine was off when the task was created". -$trigger = New-ScheduledTaskTrigger -AtStartup -$trigger.Delay = 'PT1M' -$trigger.StartWhenAvailable = $true - -$settings = New-ScheduledTaskSettingsSet ` - -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` - -ExecutionTimeLimit ([TimeSpan]::Zero) ` - -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ` - -MultipleInstances IgnoreNew - -Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` - -Settings $settings -User 'SYSTEM' -RunLevel Highest ` - -Description 'Gitea Actions runner watchdog (STM32F4-Base CI). Supervises gitea-runner daemon: waits for Gitea, restarts the daemon when it exits. Generated by scripts/ci_runner_bootstrap.ps1.' | - Out-Null +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)" # ---------------------------------------------------------------------------