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)" # ---------------------------------------------------------------------------