Files
STM32F4-Base/docs/CI/远程CI完整指南.md
T
wandering1 16be01915d
CI / 静态门禁 (pre-commit) (push) Failing after 46s
CI / 主机侧单测 (MSVC) (push) Failing after 43s
chore(docs): 补齐文件末尾换行(end-of-file-fixer 实测 18 篇)
提交后台跑 scripts/ci_local.py 时 end-of-file-fixer 报 Failed 并就地修复:
17 篇补文件末尾换行、1 篇去掉行尾空行。

内容零变化:git diff --ignore-all-space 只有 1 处空行删除;18 篇均在
上一笔流水清理里被改过,恢复补丁时把上一轮的 EOF 修复冲掉了,这一
笔补回。
2026-10-28 22:12:59 +08:00

80 KiB
Raw Blame History

远程 CI 完整指南(Gitea Actions · 本机 Windows Runner)

适用范围(先读这一段):本仓库的远程 CI 只跑在本机——runner 名 win-stm32-ci, 装在 D:\Docker\act_runner,连接的是本机原生 Gitea(http://127.0.0.1:3001)。 其他机器、其他项目都不需要、也不会用到本文这套配置,勿照搬。 日常改代码的等价自查是本地跑 scripts/ci_local.py (与 CI 同一套判定),不依赖远程 CI 是否在线——runner 掉线不影响本地门禁。

适用:为 STM32F4-Base 部署 / 排障 CI(.gitea/workflows/ci.yml,静态门禁 + 主机侧纯逻辑单测)。 前置阅读:docs/测试说明/测试开发标准流程.md §9(CI 覆盖边界与判定细节)、 docs/环境与部署/项目部署环境要求.md §2.11(runner 环境要求)。 本文只讲怎么配,不重复"为什么这么设计"。 本篇由原两篇 CI 指南 CI配置指南.md 与 Gitea_Runner部署指南.md(原位于「环境与部署」目录, 两篇均已删除)于 2026-10-12 合并而成,去重后为单一来源。

适用环境(2026-10-02 实测确认):

  • Gitea 1.25.4,Windows 原生二进制(D:\Docker\Gitea\gitea.exe,不是 Docker), 以 Windows 服务运行(tasklist 显示 Session = Services),监听 0.0.0.0:3001
  • 配置 D:\Docker\Gitea\custom\conf\app.ini,[actions] ENABLED = true 已开启 ✅
  • 数据库 MySQL(127.0.0.1:3306),仓库根 D:\Docker\Gitea\data\gitea-repositories
  • 对外域名 http://gitea.bitnasdaq.vip/(经反代),本地直连 http://127.0.0.1:3001 ✅
  • Docker Desktop 已装但 daemon 未运行 → 本文不使用容器,全程原生

⚠️ 重要版本变更(2026-10-02 实测):act_runner 已更名为 gitea-runner, 当前最新版 v4.0.1(发布页 https://gitea.com/gitea/runner/releases, 注意仓库路径已从 gitea/act_runner 变为 gitea/runner)。 v4 移除了 daemon install/start/stop/status 子命令(那些是 v0.2.x 的接口)。 另:v4 的 log 段只有 level 和 job(无 file 键),配置用 gitea-runner config init / set / get / generate 管理。

🔴 常驻方式修正(2026-10-02 实测推翻):gitea-runner daemon 是纯控制台程序, 不支持作为 Windows 服务运行(二进制内无服务管理代码,也无 service.go); 用 sc.exe / New-Service 注册后 Start-Service 必然失败。 正确方式是 Windows 任务计划程序 —— 见 §3;且 2026-10-11 实测进一步要求 在计划任务之上加看门狗循环重拉 —— 见 §2.4。


0.0 ⚠️ CI 体积门禁:从盲区到同码凭证强制(必读,「体积门禁 CI 强制路径」残余已销项)

曾经的盲区已关闭为强制路径(2026-10-25,「体积门禁 CI 强制路径」残余销项,task/A5-size-gate→564715c)。 mem-numbers 钩子的事实源是 Keil 构建产物 MDK-ARM/**/*.map(已 gitignore),CI 刻意不跑 @build → 该钩子在远端只会被跳过(现以 SKIP 名单诚实显示 Skipped,不再伪装 Passed)。 补上的强制路径 = 同码凭证:本地 @build 0/0 后 ram_baseline.py --update 顺手盖 scripts/size_attestation.json(三道前置:.map 可解析 / .map 不早于指纹内源码 mtime / 受管区 ≤92%);CI 无需 Keil,静态门禁作业末尾的「体积门禁」步用 scripts/check_size_gate.py --ci 复核两件事——工作树指纹 == 凭证指纹(改了代码从没测过 = 拦)+ 按凭证数字复跑 92% 红线。 主干 push 拦红,其余事件 --warn-only;本地 ci_local.py 的 静态门禁c 步无条件 fail-closed。

仍守不住的三项(正文唯一口径 = scripts/check_size_gate.py docstring):CI 不重算绝对数字 (只复核同码性与红线复算);编译器选项(.uvprojx)不在指纹内;凭证由本地真实构建产生, 链路本身不经 Keil 授权就无法伪造,但"盖戳后换机器环境"的差异只能靠 mtime 前置兜。

本地兜底动作不变(每次 @build 后):ram_baseline.py --update(含盖戳)+ check_mem_numbers.py --scan。若 runner 以后装了 Keil,mem-numbers 会在有 .map 时照常执行。


0. 全景:CI 需要三样东西就位

① Gitea 服务端开关        ② Windows Runner 注册        ③ 仓库里的 workflow 文件
   [actions] ENABLED=true     labels 含 windows             .gitea/workflows/ci.yml
          │                        │                              │
          └────────────┬───────────┴──────────────────────────────┘
                       ▼
             push / PR 触发 → runner 拉起容器/壳 → 跑 静态门禁 + 主机侧纯逻辑单测 → 结果回显到提交

三者缺一,CI 就是空转的(workflow 存在但永不执行 / 执行但拉不到 runner)。 当前仓库状态:③ 已就绪(本机提交 e904865,尚未 push);① ② 需人工确认。

0.1 本机拓扑:Gitea 和 Runner 在同一条机器上

Gitea 和 Runner 在同一条机器上 —— 这是本部署最关键的事实:

┌─────────────────────────── 本机 (Windows) ────────────────────────────┐
│                                                                       │
│  gitea.exe  ── Windows 服务 ── 监听 0.0.0.0:3001                      │
│      ▲                                                                │
│      │  ① runner 注册 / 心跳 / 领任务:走 http://127.0.0.1:3001        │
│      │     (本地回环,不经公网、不依赖 DNS/反代)                     │
│      │                                                                │
│  gitea-runner.exe ── 计划任务 GiteaActionsRunner(win-stm32-ci)       │
│      │                                                                │
│      └─ ② 领到 job 后,直接在**本机**跑:                              │
│           git fetch / pre-commit / cppcheck / cl.exe                   │
│         (标签 windows:host = 用宿主环境,非容器)                     │
│                                                                       │
│  外部用户 → https://gitea.bitnasdaq.vip → 反代 → 127.0.0.1:3001        │
└───────────────────────────────────────────────────────────────────────┘

两个地址要分清(最容易搞错的地方):

用途 地址 说明
Runner 连接 Gitea http://127.0.0.1:3001 本机回环,最稳。用 gitea.bitnasdaq.vip 也行,但会多绕一圈 DNS + 反代,且若反代没配对内网 IP 会失败
用户在浏览器访问 https://gitea.bitnasdaq.vip 走反代;注册时网页上显示的示例命令用的是这个域名,你要手动换成 127.0.0.1:3001

⚠️ 注册时用哪个地址,决定了 runner 以后一直连哪里。 注册信息存在 .runner 里,之后改起来要重新注册,建议一开始就用 http://127.0.0.1:3001。

0.2 部署前置清单(需人工确认)

2026-10-12 自 docs/测试说明/测试开发标准流程.md §9.4 并入——这几项是"CI 能不能跑起来"的前提, 属于机器侧知识,故与本文其余内容放在一起,不再散在测试流程文档里。

项 要求
Gitea Actions 实例开启 [actions] ENABLED = true(site admin 配置),见 §1
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 就绪 + 崩溃自拉起"的看门狗 + 计划任务,见 §2.4
机器级工具变量 runner 以 SYSTEM 运行,看不见交互用户的 PATH/用户级 site。用 ci_runner_bootstrap.ps1 -SetEnv 写入 MACHINE 级 DTU_PRECOMMIT_PYTHON / DTU_CPPCHECK / DTU_MSVC_CL,见 §4.1.3
缺 MSVC 时 主机侧纯逻辑单测降级为 WARNING 跳过(不硬失败),但等于该层裸奔,须尽快补装

1. 服务端:开启 Gitea Actions

1.1 确认版本与开关

Gitea ≥ 1.21 才内置 Actions(实测本实例 1.25.4,满足)。以 site admin 登录后:

  • 管理后台 → 站点管理 → 配置 / Site Administration → Configuration,搜索 actions:
[actions]
ENABLED = true

1.2 编辑配置文件(推荐,永久生效)

配置文件位置(按部署方式二选一):

部署方式 配置文件路径
二进制 / 直接跑 <gitea 目录>/custom/conf/app.ini
Docker 容器内 /data/gitea/conf/app.ini(挂载卷里的 custom/conf/app.ini)

在 app.ini 追加(若无 [actions] 段):

[actions]
ENABLED = true

改完 重启 Gitea 服务(Docker:docker restart <容器名>;二进制:重启进程)。

1.2.1 DEFAULT_ACTIONS_URL 是什么(容易搞反)

它不是你的 Gitea 地址,而是控制 uses: actions/checkout@v4 这类 action 从哪里下载:

取值 行为
不配(默认) 从 github.com 拉取 action
self 从你自己的 Gitea 实例拉取(要求 action 已镜像进你的实例)
具体 URL(如 https://gitea.com) 从该地址拉取

⚠️ 不要把你的实例域名写进去当普通 URL——要"从自己实例拉"就用关键字 self; 且写了 self 还得先把 action 镜像进自己实例,否则 actions/checkout 依然拉不到。

按你的网络环境选择:

场景 配置
服务器能访问 github.com 什么都不配(默认走 github.com,最省事)
内网,不能访问 github.com 建议改用零依赖写法(见本文 §5.3 方案 3),DEFAULT_ACTIONS_URL 配不配都行;
若坚持用 actions/checkout@v4,则需 DEFAULT_ACTIONS_URL = self 且把该 action 镜像进实例

Gitea 官方 FAQ 原文:"From https://github.com by default, or from your own instance when [actions].DEFAULT_ACTIONS_URL is self." 使用别处 action 也可在 workflow 里直接写全路径: uses: https://gitea.com/owner/repo@ref / uses: self:owner/repo@ref。

1.3 验证

浏览器打开仓库地址,顶部/侧栏应出现 Actions 标签页。 若仍提示"此仓库未启用 Actions",说明开关没生效,回到 1.1 检查。

⚠️ 若实例装在内网且不能访问公网,actions/checkout@v4 这类官方 action 会拉不下来。 处置见本文 §5.3。


2. Runner 端:注册 Windows Runner

CI 的两个 job 都是 runs-on: windows,所以必须有一个 labels 含 windows 的 runner。 推荐用 act_runner(Gitea 官方 runner)。

2.1 获取注册令牌

  • 仓库级 runner(推荐,只服务本仓库): 仓库 → 设置 / Settings → Actions → Runners → 创建 Runner,复制页面显示的 Registration Token(形如 xxxYYYzzz...)。
  • 全局 runner(服务整个实例):站点管理 → Actions → Runners。

记下 Gitea 地址,例如 https://gitea.bitnasdaq.vip。

本机(原生 Gitea)的具体操作路径:

  1. 浏览器登录 https://gitea.bitnasdaq.vip
  2. 进仓库 wandering1/STM32F4-Base
  3. Settings(设置)→ Actions → Runners → 创建 Runner / Create new Runner
  4. 页面会显示一段命令,形如:
./gitea-runner register --no-interactive --instance https://gitea.bitnasdaq.vip --token xxxxxxxxxxxxxxxxxxxx

复制其中的 --token 后面那一串(这是 Registration Token,一次性用途)。 页面上的 --instance 值不要照抄——见 §2.2.3。

💡 Token 存在于 action_runner_token 表(MySQL,见本文 §6.1)。 每仓库一个有效 token,注册一次后即被消费;过期(约 1 小时)或已用则需重新创建。

2.2 下载并注册 runner

本机现状(唯一口径):二进制 gitea-runner.exe v4.0.1(实测 20,623,872 B ≈ 20.6 MB), 装在 D:\Docker\act_runner,注册 --instance 用 http://127.0.0.1:3001。 具体步骤见 §2.2.1 起,命令集与字段语义见 §2.2.2 / §2.3。

🗑 历史备查,勿照抄:本文初版写的是 act_runner v0.2.11 + C:\act_runner + --instance https://gitea.bitnasdaq.vip。三点均已过时,且本机从未这样部署过 (2026-10-12 核实:C:\act_runner 至今不存在): ① v0.2.x 与 v4 的二进制名与命令集都不同(v4 已无 daemon install/start/stop/status,见 §3.0); ② 目录不同;③ --instance 填公网域名会多绕一圈 DNS + 反代,反代没配对内网 IP 就直接失败。 照抄那段会装出一个连不上 Gitea 的 runner,故原命令块已删除,只留此警示。

关键点:--labels "windows:host" 中的 windows 必须逐字与 workflow 的 runs-on: windows 对应(Gitea runner 的 label 名是自定义的,默认是 ubuntu-latest, 不注册 windows 这个 label 就永远匹配不到 job)。

:host 表示直接跑在宿主 Windows 上(不是容器)。本场景必须用 host—— 容器里拿不到宿主已装的 Keil/MSVC/cppcheck,且 Windows 容器支持很差。

2.2.1 选对版本并下载 gitea-runner

项 值
架构 amd64(PROCESSOR_ARCHITECTURE = AMD64,实测)
版本 v4.0.1(2026-10-02 实测最新稳定版)
下载页 https://gitea.com/gitea/runner/releases ← 注意仓库名已从 act_runner 改为 runner
文件名 gitea-runner-4.0.1-windows-amd64.exe ← 二进制名也从 act_runner 改为 gitea-runner
# 建目录(与 Gitea 同级,便于管理)
New-Item -ItemType Directory -Force -Path "D:\Docker\act_runner" | Out-Null
cd "D:\Docker\act_runner"

# 下载 v4.0.1(版本号请替换为 releases 页当前最新)
Invoke-WebRequest -Uri "https://gitea.com/gitea/runner/releases/download/v4.0.1/gitea-runner-4.0.1-windows-amd64.exe" `
                  -OutFile "gitea-runner.exe"

# 校验能跑
.\gitea-runner.exe --version

期望输出:gitea-runner version v4.0.1。

💡 本机实测(2026-10-02):下载 20.6 MB,耗时约 5 秒,gitea.com 可直连(HTTP 200)。

2.2.2 v4.0.1 的命令集(与 v0.2.x 的差异)

Available Commands:
  bug-report      Print information useful when filing a bug report
  cache-server    Start a cache server for the cache action
  config          Generate, read and edit config files
  daemon          Run as a runner daemon
  exec            Run workflow locally.
  help            Help about any command
  register        Register a runner to the server

⚠️ 没有 daemon install/start/stop/status —— 这些是 v0.2.x 的接口,v4 已移除。 常驻必须用 Windows 原生方式(见 §3)。

config 子命令很方便,建议优先用它而不是手改 YAML: config init(生成最小配置)/ config generate(打印全部选项) / config set <key> <value> / config get <key>。

2.2.3 执行注册(关键:instance 用 127.0.0.1)

cd "D:\Docker\act_runner"

.\gitea-runner.exe register `
  --no-interactive `
  --instance "http://127.0.0.1:3001" `
  --token "粘贴刚才复制的 Registration Token" `
  --name "win-stm32-ci" `
  --labels "windows:host"

四个参数逐一说明:

参数 值 为什么
--instance http://127.0.0.1:3001 本机回环。因为 Gitea 就在这台机器上,回环最稳、最快、不受反代/DNS 影响
--token 网页复制的那串 绑定到具体仓库(仓库级 runner)
--name win-stm32-ci runner 显示名,随意但建议有辨识度
--labels windows:host 🔴 最容易踩的坑:windows 必须与 workflow 的 runs-on: windows 逐字一致;:host 表示用宿主环境直接跑(非容器)——本场景必须 host,容器里拿不到 MSVC/cppcheck

⚠️ --labels 写错的后果:runner 会注册成功、显示绿色,但任务永远排队不执行 (没有任何 runner 匹配 runs-on: windows)。这是最隐蔽的失败模式。

期望输出:Runner registered successfully,并在 D:\Docker\act_runner\ 生成 .runner 文件(记录注册凭据)。

2.2.4 验证注册

浏览器刷新 Settings → Actions → Runners,应看到:

列 期望值
Name win-stm32-ci
Labels windows(:host 是执行方式,UI 可能只显示 windows)
Status 🟢 Idle

2.3 配置 config.yaml(可选但建议)

D:\Docker\act_runner\config.yaml 里确认/补充(本机完整落地版见 §2.3.2):

runner:
  labels:
    - "windows:host"     # 与 register 一致
  capacity: 1            # 并行 job 数;单机建议 1,避免 cppcheck 抢 CPU
  timeout: 30m0s
  fetch_timeout: 1m0s    # 🔴 不得超过 RPC timeout(1m0s),否则被自动截断并告警
cache:
  enabled: true

2.3.1 用 config 子命令管理(v4 推荐)

推荐用 config 子命令生成/修改(v4 新增,比手改 YAML 安全):

cd "D:\Docker\act_runner"
.\gitea-runner.exe config init                    # 生成最小 config.yaml
.\gitea-runner.exe config set runner.capacity 1   # 逐项设置
.\gitea-runner.exe config set runner.timeout 30m
.\gitea-runner.exe config set cache.enabled true
.\gitea-runner.exe config set log.level info
.\gitea-runner.exe config get runner.capacity     # 校验
.\gitea-runner.exe config generate                # 打印全部可用选项(含注释)

2.3.2 本机已落地的配置与字段语义

本机已落地的配置(D:\Docker\act_runner\config.yaml):

log:
  # trace/debug/info/warn/error/fatal/panic;排障时临时改 debug
  level: info
  # 每个 job 的任务日志另存一份到本地(防上报 Gitea 失败时日志丢失)
  job:
    dir: "D:\\Docker\\act_runner\\log\\job"
    retention: 168h
    max_size: 100MB

runner:
  file: .runner           # 注册凭据(由 register 生成,勿手改)
  capacity: 1             # 并行 job 数;单机建议 1(cppcheck 吃 CPU)
  timeout: 30m0s          # 单 job 超时;静态门禁+主机侧纯逻辑单测约 1~3 分钟
  fetch_timeout: 1m0s     # 🔴 不得超过 RPC timeout(1m0s):原写 5m0s 会被自动截断并刷告警,2026-10-02 已改
  fetch_interval: 2s
  # 🔴 标签:windows 必须与 workflow 的 runs-on: windows 逐字一致
  labels:
    - "windows:host"

cache:
  enabled: true
  dir: "D:\\Docker\\act_runner\\cache"

container:
  network: ""
  privileged: false
  options: ""
  valid_volumes: []
  docker_host: ""
  force_pull: false

⚠️ v4 的 log 段只有 level 和 job 两个键 —— 没有 file 键 (config set log.file ... 会报 unknown config key)。 想要本地日志用 log.job.dir(按 job 落文件),或把服务输出重定向到文件。

⚠️ 改了 runner.labels 必须重启 runner 才生效 (Gitea 官方 FAQ:"A runner declares its labels every time it starts ... so change them there and restart the runner.")

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 反推根因。

🔴 为什么"已经有计划任务"仍然会死(2026-10-11 实测,别以为注册过就安全): 旧任务跑的是 run_runner.cmd,里面是单次 gitea-runner.exe daemon ...(无循环、无重启语义)。 开机时 Gitea 往往还没起来 → daemon 首次 Declare 失败立刻退出 → 任务"成功结束" → 计划任务认为一切正常,永远不会再拉。日志里 10-09 18:50 连续三次 Starting runner daemon + 三次 Declare 失败,就是三次"起了就死、死了没人管"。 所以必须换成看门狗(等就绪 + 循环重拉),单靠计划任务的"失败重启"救不了 ——daemon 是"正常退出",不是"失败"。

正确做法:一条命令装好"等 Gitea 就绪 + 崩溃自拉起 + 开机自启"(管理员权限,仓库根目录):

powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv -StartNow

它做三件事(细节见脚本头注释):

动作 内容
① 机器级工具变量 按 CI 同一套探测写 MACHINE 级 DTU_PRECOMMIT_PYTHON / DTU_CPPCHECK / DTU_MSVC_CL(已存在则跳过,-Force 才覆盖)
② 看门狗启动器 生成 D:\Docker\act_runner\run_runner_watchdog.ps1:先轮询 <GiteaUrl>/api/v1/version 直到 Gitea 应答,再起 daemon;daemon 退出 → 记日志 → 10s 后重拉,永不放弃
③ 计划任务 GiteaActionsRunner:开机 +1 分钟触发、以 SYSTEM + 最高权限运行、运行时限=不限、失败每 1 分钟重启(×999)、不允许多实例

先干跑,再真装(不需要管理员,把 action/trigger/settings 全构建一遍并打印,什么都不写):

powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -DryRun

随时体检(只读,不改任何东西):

powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -Status

输出会告诉你:计划任务是否存在(含上次运行时间与返回码)、daemon 是否在跑、Gitea 是否可达、 三个机器级变量是否已设。

⚠️ -Status 要用管理员跑:非管理员会话不一定看得见 GiteaActionsRunner (本次排障就被这一点误导过一次,以为任务从未注册)。脚本在非管理员下会额外提示这一点。

🔴 改这个脚本时守住两条(都是踩出来的): ① StartWhenAvailable 是任务设置属性(MSFT_TaskSettings,被 MSFT_TaskSettings3 继承), 触发器上没有这个属性——写 $trigger.StartWhenAvailable 会抛 SetValueInvocationException 中断脚本(实测,且 CIM 属性列表里看不到它,只能直接赋值); ② 必须"先构建全部对象、后注销旧任务"——早期版本先 Unregister 再构建,一旦构建阶段抛异常, 旧任务已被删、新任务没建成,CI 直接裸奔。

注册成功后,回到 仓库 → 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. 常驻底层:为什么不能用 Windows 服务 + 任务计划程序手动方式

本节是 2026-10-02 的实测记录("不能用 Windows 服务"的根因)与任务对象字段说明, 保留作根因依据与手动排障参考。正式部署一律用 §2.4 的 scripts/ci_runner_bootstrap.ps1: §3.2 的旧脚本 deploy_runner_task.ps1 生成的是单次 daemon,2026-10-11 实测证明它 首次 Declare 失败就退出、死了没人管;§3.3 已改写为与现行 bootstrap 等价的看门狗任务 对象,只用于理解字段或在别的机器上手搓。

3.0 🔴 关键结论:不能用 Windows 服务

本条为 2026-10-02 实测推翻既有假设后的修正结论,务必先读。

先前文档(及 install_service.ps1)主张用 sc.exe / New-Service 把 gitea-runner daemon 注册成 Windows 服务。实测不可行,Start-Service 必然失败:

Start-Service : 由于以下错误无法启动服务"Gitea Actions Runner (gitea-runner)" ...

根因(对 gitea-runner.exe v4.0.1 二进制逆向确认):

证据 结论
命令集只有 bug-report / cache-server / config / daemon / exec / register(对应源码 internal/app/cmd/{bug_report,cache-server,cmd,config,daemon,exec,register}.go) 没有 service.go,没有任何服务管理子命令
daemon --help 仅 --labels / --once 两个 flag daemon 无服务模式开关
二进制内虽出现 OpenSCManagerW / CreateServiceW / StartServiceCtrlDispatcherW 等符号 它们来自 Docker SDK(容器作业路径),不是 runner 的服务安装代码——切勿据此误判

daemon 是纯控制台程序:它从不调用 StartServiceCtrlDispatcher, 因此 SCM 启动它后收不到 SERVICE_RUNNING 回报,判定超时 → 报错 1053。

正确做法:Windows 任务计划程序(Task Scheduler)。 它是控制台程序常驻的标准手段,功能上等价于服务:

服务能做的 任务计划程序对应能力
开机自启 触发器 -AtStartup(+ 延迟 30s 让 Gitea 先起)
不登录也运行 以 SYSTEM 账户运行
最高权限 -RunLevel Highest
崩溃自动重启 -RestartCount 999 -RestartInterval 1min
开机/手动启停 Enable/Disable/Start/Stop-ScheduledTask

3.1 前台先试跑(强烈建议)

先别急着常驻——用前台模式确认能连上:

cd "D:\Docker\act_runner"
.\gitea-runner.exe daemon --config .\config.yaml

看到这两行即成功:

level=info msg="Starting runner daemon"
level=info msg="runner: win-stm32-ci, with version: v4.0.1, with labels: [windows], declare successfully"

让这个窗口开着,去 Gitea 页面确认 runner 是 Idle,然后 Ctrl+C 停掉、再做常驻。

3.2 🗑 已废弃:deploy_runner_task.ps1(2026-10-02 方案,被 §2.4 推翻)

不要再用它部署。 它生成的 run_runner.cmd 跑的是单次 gitea-runner.exe daemon (无循环、无重启语义),而 §2.4 已实测证明 daemon 首次 Declare 失败就正常退出—— 计划任务把它记成"成功结束",-RestartCount 永不触发,于是 Gitea 重启一次 CI 就永久停摆。

脚本流程(供辨认用):提权检查 → 前置文件检查 → 清理旧 Windows 服务 → 生成 run_runner.cmd → Register-ScheduledTask(开机+登录双触发器、SYSTEM、最高权限、崩溃重启)→ 启动 → 校验存活。 它的"崩溃重启"参数看着齐全,但对"正常退出"完全无效——这正是它失效的那一点。

✅ 已于 2026-10-12 删除 D:\Docker\act_runner\deploy_runner_task.ps1 与 run_runner.cmd (删除前核实:只有 check_env.ps1 的一句提示引用它,已同步改指 ci_runner_bootstrap.ps1; 计划任务的动作目标是 run_runner_watchdog.ps1,与这两个文件无关,删除不影响在跑的 runner)。 现行部署一律用仓库里的 scripts/ci_runner_bootstrap.ps1。

3.3 手动等价命令(等价于 §2.4 的 bootstrap,用于理解任务对象)

日常请直接跑 scripts/ci_runner_bootstrap.ps1 -SetEnv -StartNow。下面是它注册的等价对象, 只在需要理解字段或在别的机器上手搓时使用:

$Dir      = 'D:\Docker\act_runner'
$launcher = Join-Path $Dir 'run_runner_watchdog.ps1'   # 由 bootstrap 生成的看门狗

# ① 动作:跑【看门狗】而不是 daemon 本体(单次 daemon 就是 §3.2 失效的根因)
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
  -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$launcher`"" `
  -WorkingDirectory $Dir

# ② 触发器:只要开机触发
$trigger = New-ScheduledTaskTrigger -AtStartup
$trigger.Delay = 'PT1M'          # 给 Gitea 启动留时间(看门狗自己也会等,双保险)

# ③ 设置
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
  -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
  -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew
$settings.StartWhenAvailable = $true    # 🔴 属【任务设置】,写在触发器上会抛异常

Register-ScheduledTask -TaskName 'GiteaActionsRunner' -Action $action -Trigger $trigger `
  -Settings $settings -User 'SYSTEM' -RunLevel Highest `
  -Description 'Gitea Actions Runner for STM32F4-Base CI (labels: windows:host)'

# ④ 启动
Start-ScheduledTask -TaskName 'GiteaActionsRunner'

⚠️ ExecutionTimeLimit = 0(不限时长)必须显式设置—— 任务计划程序默认 72 小时强制结束任务,会让 runner 每 3 天被杀一次。

⚠️ -WorkingDirectory 只对 New-ScheduledTaskAction 有效(这是它区别于 WinPS 5.1 New-Service 的地方)。SYSTEM 账户的默认工作目录不可控,所以启动器里 仍要显式 Set-Location 或用绝对路径。

🗑 旧方案还额外挂了 -AtLogOn 触发器并以 -LogonType ServiceAccount 运行。现行口径 只留开机触发:runner 与登录会话无关,多一个触发器只会让人误以为"必须有人登录才行"。

3.4 任务管理速查

Get-ScheduledTask -TaskName GiteaActionsRunner          # 状态
Get-ScheduledTaskInfo -TaskName GiteaActionsRunner      # 上次运行结果
Start-ScheduledTask -TaskName GiteaActionsRunner        # 启动
Stop-ScheduledTask  -TaskName GiteaActionsRunner        # 停止
Disable-ScheduledTask -TaskName GiteaActionsRunner      # 禁用自启
Enable-ScheduledTask  -TaskName GiteaActionsRunner      # 恢复自启
Unregister-ScheduledTask -TaskName GiteaActionsRunner -Confirm:$false   # 完全卸载

改完 config.yaml 后需 Stop 再 Start 任务才生效(纯注释改动不需要——注释不参与解析)。

🔴 重跑 ci_runner_bootstrap.ps1 会先注销再注册任务,而注销不会带走旧实例的进程: 旧看门狗变成孤儿、继续养着旧 daemon,旧 daemon 占住 .runner.lock,新看门狗就永远起不来 (症状见 §7"online 却每 10 秒重启"那行)。2026-10-12 起 -StartNow 会先停掉残留的看门狗与 daemon 再启动并回显单实例确认;-Status 也会把重复实例与锁冲突报出来。

💡 LastTaskResult 数值解读(别被非 0 吓到):

值 含义
267009 (0x41301) ✅ SCHED_S_TASK_RUNNING —— 任务正在运行,不是错误(常驻任务常态值)
0 (0x0) ✅ 上次运行正常结束
267011 (0x41303) 任务尚未运行过
267014 (0x41306) 任务被用户终止

判断 runner 是否健康,看这三项而不是 LastTaskResult: ① Get-Process gitea-runner 有进程(其父进程应是跑 run_runner_watchdog.ps1 的 powershell); ② log\runner-daemon.log 最新一行是 declare successfully; ③ Gitea 页面 runner 显示 Idle(或 MySQL 查 action_runner.last_online)。

⚠️ Get-ScheduledTaskInfo 的 Next Run Time 为空是正常的——触发器是"开机 +1 分钟", 没有下一个日历时间可报;常驻进程靠看门狗保活,不靠计划器反复拉起。别把空值当成"任务坏了"。

3.5 ⚠️ 清理遗留的无效服务

若早前按旧文档建过服务,删掉它(留着只会误导):

Stop-Service gitea-runner -Force -ErrorAction SilentlyContinue
sc.exe delete gitea-runner

gitea-runner 这个名字将不再作为服务存在。 之后提到"gitea-runner" 一律指计划任务 GiteaActionsRunner 或进程 gitea-runner.exe。

3.6 ⚠️ 权限要求(本机实测的硬约束)

注册计划任务必须用「提权的管理员」Shell。本机实测:

检查项 结果
当前用户 DESKTOP-KM0OG7Q\Administrator
是否提权(IsInRole(Administrator)) False
非提权下注册任务 ❌ 拒绝访问(Register-ScheduledTask 需要 elevated token)

"是 Administrator 用户" ≠ "有管理员权限" —— Windows 的 UAC 会把普通登录会话降权。 必须右键 PowerShell → 以管理员身份运行,标题栏会显示「管理员」字样。

💡 计划任务以 SYSTEM 账户运行,拥有完整权限,可正常访问本地 127.0.0.1:3001 的 Gitea。


4. Runner 软件环境:装什么

runner 是一台干净的 Windows,下面这些必须装,否则 job 会失败或降级跳过。

项 必需性 影响 安装
Python 3.x 🔴 必需 static-gates 环境自检失败 → job 硬失败 winget install Python.Python.3.12
cppcheck 🔴 必需 同上(cppcheck --version 探测不过) winget install Cppcheck.Cppcheck
pre-commit 🔴 必需 同上(python -m pre_commit --version 探测) pip install pre-commit 或 uv tool install pre-commit
MSVC(VS + Windows SDK) 🟡 建议 缺则 host-unit-tests 告警降级跳过(不硬失败,但 主机侧纯逻辑单测裸奔) winget install Microsoft.VisualStudio.2022.BuildTools,勾"使用 C++ 的桌面开发"
Keil / ST-LINK ⛔ 不需要 CI 刻意不跑 @build —
开发板 / 串口 ⛔ 不需要 板端测试套件/主机侧回归/人工目检不上 CI —

Git 另算:git --version 任意版本即可(你已在用)。

本机实测状态(2026-10-02):

Python    : C:\ProgramData\anaconda3\python.exe → 3.12.4        ✅
cppcheck  : C:\Program Files\Cppcheck\cppcheck.exe → 2.21.0     ✅
pre-commit: 4.6.0 (anaconda3)                                   ✅
MSVC      : VS2019 BuildTools 14.29.30133 (Hostx64\x64\cl.exe)  ✅

→ 四项全齐,无需额外安装。

4.1 关键:变量必须对 SYSTEM 可见(不是"对你可见")

runner 以 SYSTEM 服务/计划任务运行,它看到的 PATH 与 site-packages 和你登录时完全不同:

你以为 SYSTEM 实际看到 后果
python 在 PATH 里 PATH 上是 C:\msys64\ucrt64\bin\python.exe,没有 pre-commit 静态门禁环境自检失败
pip install --user pre-commit 装好了 用户级 site 对 SYSTEM 完全不可见(换任何 python 路径都没用) 同上
仓库里有 .venv .venv 已 .gitignore,runner 全新工作区根本没有 同上
cppcheck 在 PATH 里 机器 PATH 无 C:\Program Files\Cppcheck 静态门禁环境自检失败

机制补充(2026-10-02 实测):计划任务以 SYSTEM 账户运行时,继承的是系统级 PATH (注册表 HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment 的 Path), 不含任何用户级 PATH。核对方法:用 check_env.ps1(见 §9)的「系统 PATH 视角」检查项, 或以 SYSTEM 身份跑一次 where python 验证。

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 -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv

手工自检(三条都必须对 SYSTEM 成立,而不只是对你的终端成立):

python --version            # 期望 Python 3.x
cppcheck --version          # 期望 Cppcheck 2.21.0 或更高
python -m pre_commit --version

⚠️ 改了机器级变量后必须重启 runner(计划任务:Stop-ScheduledTask + Start-ScheduledTask) ——环境变量只在进程启动时读取,daemon 不重启就吃不到新值。

4.1.1 ⚠️⚠️ 关键坑:pre-commit 必须在「全局 site-packages」,装到用户态等于没装

这是 2026-10-02 排查 静态门禁 job 反复失败(::error::no Python with a SYSTEM-visible pre-commit)的真正根因, 也是本环境最隐蔽的一个坑。

现象:pre-commit 在你自己的终端里好好的(pre-commit --version 有输出), 但 CI 的 static-gates job 三个候选 Python 全部探测失败:

::error::no Python with a SYSTEM-visible pre-commit. Tried:
  C:\msys64\ucrt64\bin\python.exe |
  <workdir>\.venv\Scripts\python.exe |
  C:\ProgramData\anaconda3\python.exe

📌 上为 2026-10-02 的历史报错原文。workflow 现已改为 DTU_PRECOMMIT_PYTHON(机器级)→ PATH 的 python → 工作区 .venv 顺序(§4.1.3), 且报错行会指明是变量无效还是自动发现落空。

根因(两层):

  1. 用户态 site-packages 对 SYSTEM 不可见。 pip install pre-commit(不带提权)默认装到 user-site: C:\Users\Administrator\AppData\Roaming\Python\Python312\site-packages。 而 SYSTEM 账户的 %APPDATA% 是 C:\Windows\system32\config\systemprofile\AppData\Roaming —— 完全是另一个目录, 它根本看不到 Administrator 的 user-site。任何 python.exe 路径都救不了这个。

  2. pip 的「已满足」判定陷阱(更阴)。 就算你提权执行 pip install --no-user pre-commit,pip 在判断 「这个包是不是已经装了」时看的是整个 sys.path——其中包含 user-site。 既然 user-site 里已有同版本的包,pip 会直接打印 Requirement already satisfied: xxx in ...\roaming\...\site-packages 然后完全跳过安装到全局目录。 结果:pip 报"成功",全局目录里却什么都没有。

    实测:9 个依赖里 8 个因版本与用户态不同而被 pip 升级(侥幸落到全局), 唯独 cfgv 用户态已是同版本 3.5.0 → 被静默跳过 → -s 视角下 ModuleNotFoundError: No module named 'cfgv'。

诊断命令(-s = 禁用 user-site,模拟 SYSTEM 视角):

# 看全局目录到底有没有(而不是 pip 怎么说)
& 'C:\ProgramData\anaconda3\python.exe' -s -m pre_commit --version

# 逐个依赖点名,找出被静默跳过的那个
& 'C:\ProgramData\anaconda3\python.exe' -s -c "import cfgv, identify, nodeenv, virtualenv, distlib, filelock, platformdirs, yaml; print('all ok')"

# 反查 pip 为何跳过(会显示 "Requirement already satisfied ... roaming ...")
& 'C:\ProgramData\anaconda3\python.exe' -m pip install --no-user --dry-run cfgv

修复(必须提权):脚本 install_precommit_global.ps1 + install_precommit_global2.ps1(逐包安装 + 逐个 import 复验); 对被静默跳过的包补一支 fix_cfgv_global.ps1,核心是强制重装:

& $Py -m pip install --no-user --force-reinstall --no-deps `
    --disable-pip-version-check --no-warn-script-location cfgv

--force-reinstall 无视"已满足"判定,--no-deps 避免牵连其它包。 三个脚本都在 D:\Docker\act_runner\。

验证(四条全过才算好):

$Py = 'C:\ProgramData\anaconda3\python.exe'
& $Py -s -m pre_commit --version                      # pre-commit 4.6.2
Test-Path 'C:\ProgramData\anaconda3\Lib\site-packages\cfgv.py'   # True
# 关键:确认文件真在【全局】目录,而不是 roaming
Get-ChildItem 'C:\ProgramData\anaconda3\Lib\site-packages' -Filter 'cfgv*'

⚠️ 别再往 .venv 里装就以为 CI 能用了——CI 虽然会检查 .venv\Scripts\python.exe, 但 actions/checkout@v4 拉的是干净克隆,.venv 是 gitignore 的,根本不存在。

现象:文档链接存在性 钩子在本地 pre-commit run --all-files 通过, 一上 CI 就 Failed(exit 1),报的行/路径你在本地明明存在。

根因:CI 的 actions/checkout@v4 拉的是干净克隆,而钩子校验的是 仓库根相对路径是否存在(check_backtick_paths,见 scripts/check_doc_links.py)。 若文档引用了 gitignore 的运行时产物—— 如 .dsh/skills/serial-assistant/scripts/state/meta.json(运行时未创建,.gitignore:132 排除整个 state/)—— 你本机因为跑过工具所以文件在,克隆里则不存在 → 判为失效引用。

修复:按 docs/文档规范.md §3.3 给该行加状态词(脚本的 STATUS_HINTS: 计划 / 未创建 / 尚未创建 / 已删除 / 删除 / 不存在 / 缺失 / 断链 / 待建 / 待创建)。

| `.dsh/skills/serial-assistant/scripts/state/meta.json`(运行时未创建) | daemon 元数据(pid / 版本 / gen) |

⚠️ 状态词必须与引用在【同一行】且 ≤40 字符(STATUS_PROXIMITY)。 把说明写在上一行是无效的——脚本逐行调用 has_status_hint(line, ...), 跨行的文字不在窗口内。

本地复现 CI 条件的姿势(否则永远测不出来):

# 把运行时目录临时移开,模拟干净克隆,跑完再移回
mv .dsh/skills/serial-assistant/scripts/state .dsh/skills/serial-assistant/scripts/state.bak_ci
& '.venv\Scripts\python.exe' scripts/check_doc_links.py --all   # 或全局 python -m pre_commit run doc-links --all-files
mv .dsh/skills/serial-assistant/scripts/state.bak_ci .dsh/skills/serial-assistant/scripts/state

更一般的教训:任何校验"文件是否存在/数字是否一致"的钩子,都要在 "无本地产物"的干净克隆里验一次,别只在开发机上跑。

4.1.3 机器级 DTU_* 环境变量(runner 的正式覆盖通道,2026-10-02 起)

workflow 已改为统一消费 DTU_* 接口(见 本地工具路径环境变量方案.md §3.3): 显式变量优先 → 自动发现回退,三个 job 步骤相关的变量:

变量 作用 未设置时的回退
DTU_PRECOMMIT_PYTHON 指向装有 pre-commit 的 python.exe(静态门禁/主机侧纯逻辑单测用) PATH 的 python → 工作区 .venv(干净克隆里通常没有)
DTU_CPPCHECK 指向 cppcheck.exe(静态门禁钩子用) SYSTEM 的 PATH(用户级 PATH 不可见)
DTU_MSVC_CL 指向 x64 cl.exe(主机侧纯逻辑单测用) %ProgramFiles%/%ProgramFiles(x86)% 推导的 VS 标准根自动探测

设置(必须管理员;scope 用 Machine 才能被 SYSTEM 任务看到):

[Environment]::SetEnvironmentVariable('DTU_PRECOMMIT_PYTHON', 'C:\ProgramData\anaconda3\python.exe', 'Machine')   # 示例:本机 runner
[Environment]::SetEnvironmentVariable('DTU_CPPCHECK',         'C:\Program Files\Cppcheck\cppcheck.exe', 'Machine')
[Environment]::SetEnvironmentVariable('DTU_MSVC_CL',          'C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Tools\MSVC\14.29.30133\bin\Hostx64\x64\cl.exe', 'Machine')
# ★ 设完必须重启 runner 任务(环境块是任务启动时快照的)
Stop-ScheduledTask  -TaskName GiteaActionsRunner
Start-ScheduledTask -TaskName GiteaActionsRunner

与 §4.1.1 的关系:机器级 DTU_PRECOMMIT_PYTHON 是正式通道(指向哪个解释器由你说了算, 值无效时 job 直接报变量名、不猜);"把 pre-commit 装进全局 site-packages"仍是兜底 (变量未设 + 全局 site 可见也能过)。两条路并存不冲突:变量设了就用变量,没设走探测。 显式变量值无效(文件不存在 / 无 pre-commit 模块)→ job 硬失败并指明变量名——不会 静默回退到别的解释器,避免 runner 悄悄换了 Python 导致钩子行为漂移。

4.2 MSVC 的路径要求(探测逻辑)

host-unit-tests 的 MSVC 探测优先读机器级 DTU_MSVC_CL(x64 cl.exe 完整路径; 值无效 → 硬失败、不静默回退);未设置时按标准布局自动探测(根目录由 %ProgramFiles / %ProgramFiles(x86) 推导,不写死盘符;与 test/host/common/unit_test_harness.py 的 _default_vs_roots() 保持一致): 2019 → Program Files (x86);2022 / 2026(实际目录名 18)→ Program Files, 各取 BuildTools/Community/Professional/Enterprise 子目录,找 VC\Tools\MSVC\*\bin\Hostx64\x64\cl.exe。

本机实测命中第 1 条(2026-10-02): C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\VC\Tools\MSVC\14.29.30133\bin\Hostx64\x64\cl.exe ✅

⚠️ 装在完全非标准位置(如 D 盘自定义目录)会探测不到 → 被当成"没装 MSVC"跳过 主机侧纯逻辑单测(仅告警)。 处置(二选一):设机器级 DTU_MSVC_CL 指向 cl.exe(推荐;值无效会硬报错), 或把 VS 装回标准位置(探测按 %ProgramFiles* 推导,换盘符的标准布局仍命中)。

Windows SDK 同理:默认 <ProgramFiles(x86)>\Windows Kits\10\;非标准位置用 DTU_WINDOWS_KITS_ROOT 指定。

📌 2026-10-02:workflow 与本地 harness 同步改为"变量优先 + 标准布局推导" (本地工具路径环境变量方案.md)——旧写法要求"同步改两处",现已不需要。

4.3 验证运行器能找到工具链(注册前先跑一遍)

& '.venv\Scripts\python.exe' scripts/ci_local.py

这条命令跑的就是 CI 的同一套判定。本地能过,runner 上基本就能过(前提是 PATH 一致)。


5. 仓库端:workflow 文件

5.1 文件位置与提交

workflow 必须位于仓库根的 .gitea/workflows/ 下(不是 .github/):

<仓库根>/
└─ .gitea/
   └─ workflows/
      └─ ci.yml        ← 本仓库已有,见该文件

它必须被 push 到远程才能生效——目前本仓库的 e904865 还在本地 (origin/v2-on-ui 尚未包含),所以现在 push 之前 CI 不会跑:

git push origin v2-on-ui     # ⚠️ 需你明确要求才推送(本仓库约定:默认不推送)

5.2 触发条件

on:
  push:
    paths-ignore: ['docs/**', '**/*.md', '.workbuddy-ai/**']
  pull_request:
    paths-ignore: ['docs/**', '**/*.md', '.workbuddy-ai/**']
  workflow_dispatch:        # 页面可手动触发
  • 纯文档改动不触发(省 runner);
  • 想手动跑一次:仓库 → Actions → 选 CI → Run workflow。

5.3 内网无公网时的 action 拉取(重要)

workflow 用了 actions/checkout@v4。默认从 github.com 拉取(见 §1.2.1; 不是从你自己的实例,也不是从 gitea.com —— 这点很反直觉);实例不通 github.com 会在 checkout 步骤卡住/失败。三种处置:

  1. 镜像到自有实例:DEFAULT_ACTIONS_URL = self,并先把 actions/checkout 导入你自己的 Gitea;
  2. 指向可达镜像:DEFAULT_ACTIONS_URL 指向某个能访问的 action 站(或在 workflow 里 直接写全路径 uses: https://<host>/owner/repo@ref);
  3. 零依赖(内网最稳,推荐):把 checkout 换成纯 git 命令,不依赖任何 action:
      - name: Checkout
        shell: pwsh
        run: |
          git init .
          git remote add origin $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git
          git fetch --depth 1 origin $env:GITHUB_SHA
          git checkout $env:GITHUB_SHA

方案 3 无需任何外部 action 与任何 DEFAULT_ACTIONS_URL 配置;代价是失去 checkout 的缓存优化 (对本仓库影响很小)。 参考:Gitea FAQ「Where will the runner download scripts when using actions such as actions/checkout@v4?」

本机环境判断(2026-10-02):Gitea 装了 OFFLINE_MODE = true,说明这台机器的外网访问 可能受限。如果 runner 拉不到 github.com,checkout 步骤会卡住。

⚠️ 若走方案 1:self 是关键字,不是写你的域名(见 §1.2.1)。

建议:先按原样试一次。若 checkout 步骤报网络错误,再换成上面的零依赖写法(方案 3)。

5.4 产物存档与结果汇总(2026-10-02 起)

目标:CI 失败时不查 MySQL / 不翻 runner 磁盘,在 Gitea 页面即可看到完整输出与摘要。

零依赖方案(内网首选,不用 actions/upload-artifact):

手段 说明
Tee-Object 落盘 两个 job 把关键输出 Tee 到 ci-artifacts/static-pre-commit.log、ci-artifacts/host-unit-<模块>.log(留在 runner 工作区,已 gitignore)
$GITHUB_STEP_SUMMARY 写入 job 的 step-summary(Gitea UI 运行页直接可见,无需下钻日志)
::notice:: 汇总行 控制台醒目行,便于日志检索

汇总行格式:

层 汇总行
静态门禁 static: pre-commit hooks -- passed=N failed=M skipped=K (log: ci-artifacts\static-pre-commit.log)
主机侧纯逻辑单测 host-unit: <K> runner(s), <A>/<B> assertions passed, fail=<F>(逐模块另有 ::notice::<模块>: N/M PASSED (failed=0))

⚠️ ci-artifacts/ 已加入 .gitignore:它是 runner 工作区的运行时产物,不要提交。

⚠️ 若后续改为 actions/upload-artifact(需确认实例能拉该 action):把 Tee 落盘 保留即可,上传步骤叠加在其后;本方案的落盘文件即上传源。

PowerShell 陷阱(已在 workflow 内处理):runner 注入 $ErrorActionPreference='Stop', 而 & native 2>&1 | Tee-Object 会把 native 的 stderr 变成 ErrorRecord → 在 Stop 下整段中止。 故 pipeline 前后需 Save/Restore 该偏好为 Continue(与"环境自检"步骤同法)。 $LASTEXITCODE 在 Tee-Object 之后仍保留 native 退出码(实测),可直接用于判定。

5.5 两个 job 的步骤要点(2026-10-12 自 docs/测试说明/测试开发标准流程.md §9.2 并入)

job runs-on 步骤要点
static-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(见 §7 排障表"钩子条数对不上"行)→ 决定跳过哪些钩子(探测 .map,写入 GITHUB_OUTPUT;无 .map 时 ::warning:: 声明体积门禁未跑)→ pre-commit run --all-files --show-diff-on-failure(env: SKIP,输出 Tee 到 ci-artifacts/static-pre-commit.log)→ 打印 ::notice::static: passed/failed/skipped 汇总行 → 归档日志说明
host-unit-tests windows checkout → 探测 MSVC(按 VS_ROOTS 找 Hostx64\x64\cl.exe)→ 遍历 test/host/**/run_unit_test.py 逐个跑(输出 Tee 到 ci-artifacts/host-unit-<模块>.log;解析各模块 N/M PASSED 汇总行 → 聚合 ::notice::host-unit: K runner(s), A/B assertions passed;失败 ::error:: 并置退出码);未探测到 MSVC 时打印醒目 WARNING 并降级跳过(不硬失败,避免"没装 VS 的 runner 全红")

🔴 两条 Python 探测链必须同序同步:本地 scripts/ci_local.py 与 CI 侧 scripts/ci_find_python.ps1 的候选顺序要一致。2026-10-11 那次 静态门禁全灭,就是 workflow 单方面把候选收窄成"PATH 上的 python + 仓库 .venv"(而 runner 上这两条都是死路: PATH python 没装 pre-commit、.venv 被 gitignore 根本不存在)导致的。改一边必须改另一边。


6. 端到端验证清单

按顺序确认,任一步不过就停在那里排障:

# 检查 期望
1 仓库顶部有 Actions 标签页 服务端开关已开
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 成立,见 §4.1)
5 git push 一次(或页面 Run workflow) Actions 页出现运行中任务
6 static-gates 日志 using python (…) + runner environment check passed;::notice::static: pre-commit hooks -- passed=.. failed=0 skipped=..;无 .map 时见 ::warning:: 体积门禁未跑
7 static-gates 的 提交信息门禁 步骤 ::notice::gitlint: N commit message(s) checked, 0 failed(这一行以前根本不存在,见 §7)
8 host-unit-tests 日志 ::notice::host-unit: <N> runner(s), A/A assertions passed, fail=0。N = test/host/ 下 run_unit_test.py 的个数(2026-10-12 实测:master 3 个 / 288 断言,v2-on-ui 10 个);断言总数随单测增减而变,判定只看 fail=0 且 A==B,不要拿历史数字当基准
9 job 运行页的 Summary 区块 出现 "静态门禁 static gates" / "Commit message gate (gitlint)" / "主机侧纯逻辑单测 host-side unit tests" 小标题与汇总行
10 提交记录旁出现 ✅ CI 全绿

本地先自查(与 CI 同一套判定,避免推上去才发现问题):

& '.venv\Scripts\python.exe' scripts/ci_local.py

⚠️ 第 5 步的前提(2026-10-02 记录)是当时的最大阻塞:本仓库的 .gitea/workflows/ci.yml 还在本地 (提交 e904865 / 1e01950 / 52e6dcb / c305a8d 均未 push)。workflow 不上远程,CI 永远不会跑, 哪怕 runner 配得再完美。

6.1 无网页登录时的状态核验(MySQL 直查)

runner 是否在线,可以直接查 Gitea 数据库确认(本机 MySQL 凭据见 D:\Docker\Gitea\custom\conf\app.ini 的 [database] 段):

MYSQL="/c/Program Files/MySQL/MySQL Server 8.4/bin/mysql.exe"
"$MYSQL" -h 127.0.0.1 -P 3306 -u wandering -p<pwd> -D gitea -e \
  "SELECT id,name,version,last_online,agent_labels FROM action_runner;"
字段 判读
name / version 注册名与版本(应为 win-stm32-ci / v4.0.1)
agent_labels 应为 ["windows"] ← 不含 windows 就是标签配错了
last_online Unix 秒;与当前时间相差 < 30 秒即在正常心跳
date +%s   # 对比 last_online

相关表:action_runner(已注册 runner)、action_runner_token(注册令牌, 字段 repo_id 对应 repository.id)。


7. 常见故障排查

现象 根因 处置
运行状态是 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 复查
static-gates 报 no Python with a SYSTEM-visible pre-commit runner 是 SYSTEM:PATH 上的 python 无 pre-commit、用户级 site 不可见、.venv 未入库(§4.1 三重坑) ci_runner_bootstrap.ps1 -SetEnv 写机器级 DTU_PRECOMMIT_PYTHON,或把 pre-commit 装进机器级解释器;改完必须重启 runner
static-gates 报 runner missing cppcheck 机器 PATH 无 Cppcheck 且无机器级 DTU_CPPCHECK -SetEnv(会自动发现 C:\Program Files\Cppcheck)或装 Cppcheck.Cppcheck
钩子条数对不上(CI 报告条数 = 定义数 − 1;2026-10-12 实测 master 18 定义 / 17 报告,v2-on-ui 26 定义 / 25 报告——增删钩子的分支必须重算这个差值) gitlint 的 stages: [commit-msg] 写在上游 .pre-commit-hooks.yaml(项目 .pre-commit-config.yaml 里 grep 不到,别据此断定"没限制"),pre-commit run --all-files 跑不到它,且表现为全绿——静默空转 已由 scripts/ci_gitlint_range.ps1 单独校验;以后增删钩子必须核对"定义数 == 日志条数 + gitlint"
gitlint 步骤 Failed 但提交信息其实合规(同一条命令在本地全绿),日志里是 exit code: 254 + Missing git configuration: please set user.name actions/checkout 把 HOME 临时指向每次任务的临时目录,而 SYSTEM 账号没有全局 git 身份 → gitlint 在读到任何规则之前就因配置错误退出(254 不是规则违规)。旧脚本末尾那句 ::error::...rule: single-line Chinese title, < 100 chars 是写死的猜测文案,把环境问题伪装成"提交信息不合格";且该步 exit 1 会让后面的 --all-files 整个不跑——看着像"门禁拦住了",实际 26 个钩子一个都没执行 ci_gitlint_range.ps1 先探测身份,缺失就用 GIT_CONFIG_COUNT/KEY/VALUE 注入一次性身份(不写任何文件,开发者机器不受影响),并把 254 与真实违规分开报(2 = 门禁没跑起来,1 = 信息确实违规)。实测同一命令:空 HOME 下改前 254、改后 0 failed(2026-10-12 task 31 首次暴露)
push 后 Actions 页空白/无任务 ① 服务端开关未开 ② workflow 没 push 到远程 ③ 只改了 docs/**(被 paths-ignore 滤掉) ④ 新建分支的首次 push(实测 2026-10-12:创建 v2-on-ui-ci 那次 push 没产生 run——runner 在线空闲、job 日志零新增;紧接着一次普通 push 立刻出 task。原因:before 是全零 SHA,变更文件集算不出来,paths-ignore 求值时被滤空) ①②③ 逐条核对 §1 / §5.1 / §5.2;④ 用 workflow_dispatch 手动触发,或再推一个非文档路径的提交
任务一直 Waiting / 排队 没有 label 匹配 windows 的 runner 检查 --labels "windows:host";Settings→Runners 是否 Idle
static-gates 报 ::error::runner 缺少:Python, cppcheck runner 没装或不在 PATH 装完后重启 runner 服务(新进程才吃到新 PATH)
checkout 步骤卡住/超时 拉不到 actions/checkout@v4(内网) 见 §5.3
l05 日志出现 ::warning::runner 未安装 MSVC VS 装在非默认路径或未装 见 §4.2(默认路径 / 同步改两处根目录)
mem-numbers 显示 Skipped 正常(CI 无 Keil,无 .map) 无需处理;但注意 CI 未守体积门禁(见 §0.0),改代码后本地 @build + check_mem_numbers.py --scan 兜底
主机侧纯逻辑单测显示 2 runner(s), X/Y ... 但 X<Y 有单测用例未通过 看该模块的 ::error::unit test failed 行与其 ci-artifacts/host-unit-<模块>.log
主机侧纯逻辑单测报 produced no '=== ... N/M PASSED ===' summary line 该模块运行器未输出汇总行(改了 harness 或运行器崩溃) 核对 test/host/common/unit_test_harness.py 的 summary_marker 与该模块 TestBuild 是否一致
Tee-Object 步骤报 TerminatorExpectedAtEndOfString / 字符串缺少终止符 run: 块混入了非 ASCII(GBK 解码错位) 见文件头 🔴🔴;run: 内必须纯 ASCII
l05 报 fatal error C1083: 无法打开包括文件 "stdio.h" Windows SDK 缺失 装 VS 时勾"使用 C++ 的桌面开发"+ SDK
单测退出码 2 环境错误(多为无 MSVC) 同上
pre-commit 首次运行久 首次要下载各钩子的环境 正常,runner 缓存后会快
daemon 启动报 connection refused / dial tcp --instance 填了不可达地址 删 .runner → 重新 register,--instance http://127.0.0.1:3001
启动报 token 相关错误 Registration Token 过期(约 1 小时)或已用过 回网页重新创建 runner 拿新 token,重新 register
任务跑到一半报 python: 不是内部或外部命令 SYSTEM 账户 PATH 没有 Python 确认 Python 在系统 PATH → 重启任务(§4.1)
改了 .runner / 换机器 注册信息失效 删 .runner → 重新 register
daemon install 报 accepts at most 0 arg(s) v4 已移除该子命令,且无任何服务子命令 改用任务计划程序(§3)
🔴 Start-Service gitea-runner 报 无法启动服务(错误 1053) daemon 不是服务型程序,不向 SCM 回报 SERVICE_RUNNING 删除该服务,改用任务计划程序(§3.0 / §3.5)
Register-ScheduledTask 报 拒绝访问 Shell 未提权(IsAdmin=False) 右键 PowerShell → 以管理员身份运行(§3.6)
任务 LastTaskResult 非 0 启动器路径/权限问题,或任务被手工终止 看 log\runner-watchdog.log 与 log\runner-daemon.log;确认 run_runner_watchdog.ps1 存在且为纯 ASCII;0x41301 是"正在运行"不是错误
🔴 runner 网页显示 online,但 runner-watchdog.log 每 10 秒刷 starting daemon → daemon exited, exit code 1 两个看门狗抢 .runner.lock:Unregister-ScheduledTask 不会带走旧实例启动的进程,孤儿看门狗养着的旧 daemon 仍占着锁,新看门狗起不来(daemon 日志写 another gitea-runner process is already using ".runner" / runner file is already locked by another process)。此时 online 是旧 daemon 在硬撑,任务实际谁也没管 scripts\ci_runner_bootstrap.ps1 -Status 会报出重复实例;管理员跑 -StartNow(现已会先清残留进程再启动,runner 约离线几秒)。应急可手工:Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" | ? CommandLine -match 'run_runner_watchdog' 找出多个 pid,杀掉旧的那个,再 taskkill /PID <旧 daemon> 让在册看门狗接管。✅ 2026-10-12 实测收敛:-StartNow 输出 stopping stale watchdog pid 26900 / 19716 → stopping stale daemon pid 53848 → daemon up: pid 40360 (single instance, holds .runner.lock);之后看门狗日志 25 秒零增长、runner 回 online。切换瞬间新看门狗可能先失败 1~2 次(旧 daemon 刚被杀、锁尚未释放),10 秒重拉后自愈,属预期
static-gates 报 Cannot find: pwsh in PATH shell: pwsh = PowerShell 7(本机只有 Windows PowerShell 5.1) 改 shell: powershell
🔴 doc-links 钩子本地过、CI 挂 引用了 gitignore 的运行时文件(如 .dsh/skills/serial-assistant/scripts/state/*.json),本地存在、干净克隆里没有 该行加状态词「未创建/不存在」等(§4.1.2)
任务跑满 72 小时被杀 计划任务默认 ExecutionTimeLimit 设为 0(不限时长),见 §3.3 警示
config set log.file ... 报 unknown config key v4 的 log 段无 file 键 用 log.job.dir 或输出重定向(§2.3)
脚本报一堆 源文本中包含无法识别的标记 / 字符串缺少终止符 / 中文显示成 鐒跺悗鎵ц 🔴 .ps1 无 BOM,WinPS 5.1 按 GBK 解读 UTF-8 给脚本加 UTF-8 BOM(§7.1)

7.1 🔴 必读:给含中文的 .ps1 加 UTF-8 BOM

这是本机实测踩过的坑(2026-10-02)。Windows PowerShell 5.1 对无 BOM 的脚本文件 按系统 ANSI 代码页(简中 = GBK / CP936)解码:

  • UTF-8 的中文字节被当 GBK 解 → 显示成 鐒跺悗鎵ц 这类乱码;
  • 更糟的是,乱码字符会撞坏字符串引号配对(GBK 双字节的尾字节可能是 " 或 \) → 解析器报 源文本中包含无法识别的标记 / 字符串缺少终止符: " / 缺少右"}" —— 报错行号看起来毫无规律,极易误判成逻辑问题。

修法:给文件头加 3 字节 EF BB BF(UTF-8 BOM)。与仓库里带 BOM 的源文件 (Drivers/BSP/UI/*、App/cfg_file.c)是同一个道理。

# 方法 A:PowerShell 重存为 UTF8BOM
$c = Get-Content "D:\Docker\act_runner\check_env.ps1" -Raw -Encoding UTF8
[System.IO.File]::WriteAllText("D:\Docker\act_runner\check_env.ps1", $c, (New-Object System.Text.UTF8Encoding($true)))
# 方法 B:直接前缀 BOM 字节(bash/Git Bash)
cd "/d/Docker/act_runner"
for f in check_env.ps1; do
  printf '\xef\xbb\xbf' > "$f.tmp" && cat "$f" >> "$f.tmp" && mv "$f.tmp" "$f"
done

校验:用 PowerShell 解析器(不执行)确认语法干净:

foreach ($f in @("check_env.ps1","run_runner_watchdog.ps1")) {
    $errs = $null
    $null = [System.Management.Automation.Language.Parser]::ParseFile(
        "D:\Docker\act_runner\$f", [ref]$null, [ref]$errs)
    if ($errs.Count) { "$f : FAIL -> $($errs[0].Message)" } else { "$f : OK" }
}

💡 替代方案:改用 PowerShell 7+(pwsh),它默认按 UTF-8 读脚本、无需 BOM。 但本机只有 WinPS 5.1,故加 BOM 最省事。

💡 纯 ASCII 的脚本不受影响,也不需要 BOM。现行两个文件的口径: check_env.ps1 含中文注释 → 必须带 BOM;run_runner_watchdog.ps1(由 scripts/ci_runner_bootstrap.ps1 生成)刻意写成纯 ASCII,因此对编码免疫—— 生成它的模板也要求纯 ASCII,理由见仓库 AGENTS.md 的编码铁律。 🗑 旧脚本 deploy_runner_task.ps1 已废弃(§3.2),不再作为示例对象。

7.2 日志位置与查看方式

计划任务模式下看日志(两种):

日志 位置
runner 自身 stdout/stderr D:\Docker\act_runner\log\runner-daemon.log(由看门狗落盘,显式 -Encoding utf8;2026-10-12 之前的行夹带 PowerShell 包装噪声,见 §7.3)
看门狗自身动作(等 Gitea / 拉起 / 退出码 / 重拉) D:\Docker\act_runner\log\runner-watchdog.log(ASCII)
每个 job 的详细日志 D:\Docker\act_runner\log\job\
🗑 历史遗留 log\runner-stdout.log 是旧 run_runner.cmd 时代的文件,已不再写入;它内部混着 ASCII 与 UTF-16LE 两种编码(PS 5.1 *>> 默认 UTF-16LE),按 UTF-8 读会看到"每个字节后跟一个 NUL"的 t i m e = ... 形态,属正常现象不是新故障

看日志:v4 的 log 段没有 file 键,两种落盘方式:

# 方式 A:按 job 落文件(推荐,防上报失败丢日志)
log:
  level: info
  job:
    dir: "D:\\Docker\\act_runner\\log\\job"
# 方式 B:前台运行 / 包装脚本里重定向。
# 🔴 不要写成 `.\gitea-runner.exe daemon > log 2>&1`:PS 5.1 的 `>` 就是 Out-File,
#    默认编码 UTF-16LE,且会把原生 stderr 包成 ErrorRecord(见 §7.3)。要这样写:
& '.\gitea-runner.exe' daemon 2>&1 | ForEach-Object { "$_" } |
    Out-File -FilePath 'D:\Docker\act_runner\log\runner.log' -Append -Encoding utf8

任务本身的历史记录:任务计划程序 → 任务计划程序库 → GiteaActionsRunner → 历史记录; 或用 Get-ScheduledTaskInfo -TaskName GiteaActionsRunner 看 LastTaskResult。

7.3 🔴 原生命令 stderr 的两个 PS 5.1 陷阱(本仓库实测,均已修)

runner 的日志走 stderr,于是上面两个日志文件都栽在这两条上。两条都在 scripts/ci_runner_bootstrap.ps1 生成的启动器里修掉了,改脚本时勿回退:

① 重定向的默认编码是 UTF-16LE。 *>> / > 在 PS 5.1 里就是 Out-File,默认 Unicode(UTF-16LE)。同一文件里若还有 Add-Content -Encoding ascii 写的行,就会 一个文件两种编码,按 UTF-8 读出来是 t i m e = 2 0 2 6 ...(每字节后跟 NUL)。 → 永远显式写 -Encoding utf8(别用 ascii:job/提交文本可能含非 ASCII)。

② 2>&1 把原生 stderr 包成 ErrorRecord。 实测(用无效子命令产生真实 stderr):

写法 2 行真实日志写成 噪声
& $exe 2>&1 | Out-File 8 行 gitea-runner.exe : 前缀 + 4 行 CategoryInfo/FullyQualifiedErrorId 块
& $exe 2>&1 | ForEach-Object { "$_" } | Out-File 2 行 0
& cmd.exe /c "… 2>&1" | Out-File 2 行 0(等价,但要额外操心 cmd 对含空格路径的引号剥离)

即每 4 行日志有 3 行是 PowerShell 噪声,而且它就出现在文档让大家第一个打开的文件里。 ErrorRecord.ToString() 就是原始 stderr 行,所以逐项字符串化即可去掉装饰, 且 $LASTEXITCODE 不受影响(看门狗靠它打印退出码)。

💡 顺带一条同类教训:别用"日志尾部还有没有错误串"判断健康状态。日志不轮转, 修好之后旧失败仍留在尾部几十行内——-Status 曾据此对健康 runner 报假警。 现在改看进程存活时长(活过 10 秒重拉间隔就不可能在重启循环里)。


8. 日常维护

  • 每周(或每次改完 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 单独守(见 §7);
  • @build / 板端测试套件 / 主机侧回归:永远留在本地,不要往 CI 加(需 Keil 授权与真机);
  • runner 长时间不可用时:CI 只是"多一道网",提交前的本地 ci_local.py 仍是 静态门禁的唯一保障, 不能因"已经配了 CI"放宽(见 docs/任务规划/登记簿.md 的条目「CI 远端闭环未验证」)。

9. 一键检查脚本(可选)

存为 D:\Docker\act_runner\check_env.ps1,注册前跑一遍:

Write-Host "=== Gitea 可达性 ===" -ForegroundColor Cyan
try {
    $v = (Invoke-RestMethod -Uri "http://127.0.0.1:3001/api/v1/version" -TimeoutSec 8).version
    Write-Host "  OK  Gitea $v (127.0.0.1:3001)" -ForegroundColor Green
} catch { Write-Host "  FAIL 连不上 Gitea: $_" -ForegroundColor Red }

Write-Host "=== 工具链 ===" -ForegroundColor Cyan
foreach ($c in @("python", "cppcheck", "git")) {
    $p = Get-Command $c -ErrorAction SilentlyContinue
    if ($p) { Write-Host "  OK  $c -> $($p.Source)" -ForegroundColor Green }
    else    { Write-Host "  FAIL 缺 $c" -ForegroundColor Red }
}
$pc = (python -m pre_commit --version 2>&1)
if ($LASTEXITCODE -eq 0) { Write-Host "  OK  $pc" -ForegroundColor Green }
else { Write-Host "  FAIL 缺 pre-commit" -ForegroundColor Red }

Write-Host "=== DTU_* 机器级变量(§4.1.3)===" -ForegroundColor Cyan
foreach ($n in @("DTU_PRECOMMIT_PYTHON", "DTU_CPPCHECK", "DTU_MSVC_CL")) {
    $v = [Environment]::GetEnvironmentVariable($n, "Machine")
    if ($v) { Write-Host "  OK  $n = $v" -ForegroundColor Green }
    else    { Write-Host "  --  $n(未设置:workflow 将走自动发现)" -ForegroundColor Yellow }
}

Write-Host "=== MSVC cl.exe(DTU_MSVC_CL 优先,否则标准布局推导)===" -ForegroundColor Cyan
if ($env:DTU_MSVC_CL -and (Test-Path $env:DTU_MSVC_CL)) {
    Write-Host "  OK  $($env:DTU_MSVC_CL)(来自 DTU_MSVC_CL)" -ForegroundColor Green
} else {
    $pf   = ${env:ProgramFiles}
    $pf86 = ${env:ProgramFiles(x86)}
    $roots = @(
        (Join-Path $pf86 'Microsoft Visual Studio\2019\BuildTools'),
        (Join-Path $pf86 'Microsoft Visual Studio\2019\Community'),
        (Join-Path $pf86 'Microsoft Visual Studio\2019\Professional'),
        (Join-Path $pf   'Microsoft Visual Studio\2022\BuildTools'),
        (Join-Path $pf   'Microsoft Visual Studio\2022\Community'),
        (Join-Path $pf   'Microsoft Visual Studio\2022\Professional'),
        (Join-Path $pf   'Microsoft Visual Studio\2022\Enterprise'),
        (Join-Path $pf   'Microsoft Visual Studio\18\BuildTools'),
        (Join-Path $pf   'Microsoft Visual Studio\18\Community')
    )
    $hit = $null
    foreach ($r in $roots) {
        if (Test-Path $r) {
            $hit = Get-ChildItem -Path $r -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue |
                   Where-Object { $_.FullName -like '*Hostx64\x64*' } | Select-Object -First 1
            if ($hit) { break }
        }
    }
    if ($hit) { Write-Host "  OK  $($hit.FullName)" -ForegroundColor Green }
    else { Write-Host "  FAIL 未找到 MSVC (Hostx64\x64\cl.exe);可设机器级 DTU_MSVC_CL" -ForegroundColor Red }
}

10. 最小操作清单(TL;DR)

# 1) 下载(v4.0.1;注意仓库名是 gitea/runner,二进制名是 gitea-runner)
New-Item -ItemType Directory -Force -Path "D:\Docker\act_runner" | Out-Null
cd "D:\Docker\act_runner"
Invoke-WebRequest -Uri "https://gitea.com/gitea/runner/releases/download/v4.0.1/gitea-runner-4.0.1-windows-amd64.exe" -OutFile "gitea-runner.exe"

# 2) 注册(token 从网页 Settings→Actions→Runners 拿;instance 用回环)
.\gitea-runner.exe register --no-interactive `
  --instance "http://127.0.0.1:3001" `
  --token "<粘贴 token>" `
  --name "win-stm32-ci" `
  --labels "windows:host"

# 3) 前台试跑,确认能连上
.\gitea-runner.exe daemon       # 看到 "labels: [windows] ... declare successfully" 即成功,Ctrl+C 停

# 4) 常驻 —— ⚠️ daemon 不是服务型程序,且首次 Declare 失败就直接退出进程
#    所以必须【看门狗循环 + 开机计划任务】,一条命令搞定(管理员 PowerShell,在仓库根目录):
powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -SetEnv -StartNow

#   体检(四项:计划任务 / daemon 进程 / Gitea 可达 / 机器级 DTU_*)—— 须管理员:
powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -Status
#   干跑(只打印将要写入的启动器与任务对象,不落盘不注册)—— 免管理员:
powershell -ExecutionPolicy Bypass -File scripts\ci_runner_bootstrap.ps1 -DryRun

# 5) 工具链必须对 SYSTEM 可见(runner 以 SYSTEM 跑,看不见交互用户的 PATH 与用户级 site)
#    正式通道 = 机器级 DTU_* 变量,上一步的 -SetEnv 已自动发现并写入;需手工覆盖时:
#    (管理员 PowerShell;下面是本机实测现值,换机按实际路径改)
# [Environment]::SetEnvironmentVariable('DTU_PRECOMMIT_PYTHON', 'C:\ProgramData\Anaconda3\python.exe', 'Machine')
# [Environment]::SetEnvironmentVariable('DTU_CPPCHECK', 'C:\Program Files\Cppcheck\cppcheck.exe', 'Machine')
# [Environment]::SetEnvironmentVariable('DTU_MSVC_CL', '<本机 x64 cl.exe 完整路径>', 'Machine')
#    兜底通道(只有变量没设时才会走到):把 pre-commit 装进【全局】site-packages,见 §4.1.1
#    ⚠️ 改过任何环境变量都必须 Stop 再 Start 任务——环境块是任务启动时的快照

# 6) 复验(-s = 模拟 SYSTEM 视角,必须过)
& 'C:\ProgramData\Anaconda3\python.exe' -s -m pre_commit --version    # pre-commit 4.6.2

# 7) 回网页确认 runner 为 Idle → 触发 CI
#    push 触发(注意:纯 .md 改动在 workflow 的 paths-ignore 里,不会触发);
#    手动触发:
#    curl -u "<user>:<pass>" -X POST -H "Content-Type: application/json" `
#         -d '{"ref":"v2-on-ui"}' `
#         "https://gitea.bitnasdaq.vip/api/v1/repos/wandering1/STM32F4-Base/actions/workflows/ci.yml/dispatches"

🗑 本清单第 4 步在 2026-10-02 版里是"计划任务跑单次 daemon"(deploy_runner_task.ps1)。 2026-10-11 实测证明单次 daemon 首次 Declare 失败即退出、计划任务的失败重启救不到它, 故已改写为看门狗方案;旧脚本的失效机理见 §3.2。

CI 验收标准:两个 job 都要 status=1。口径是规则,不是具体数字(2026-10-12): static-gates 报 定义数 − 1 条钩子(gitlint 属 commit-msg stage,不进 --all-files 报告, 见 §7),其中 mem-numbers 在无 .map 时为 Skipped(本机唯一常见跳过项);另有提交信息 门禁 ::notice::gitlint: N checked, 0 failed。host-unit-tests 报 <N> runner(s), A/A assertions passed, fail=0,N = test/host/ 下 run_unit_test.py 个数。 实测快照:master 18 定义 / 17 报告、3 套件 / 288 断言;v2-on-ui 26 定义 / 25 报告、10 套件。 🗑 更早快照(勿当基准):run#8(2026-10-02)当时是"14 个钩子全 Passed + 39/39 单测"。 判定只看 fail=0、failed=0 与 A==B;增删钩子或单测后按上面的规则重算,别抄历史数字。

配套脚本:

脚本 位置 用途 需要管理员
ci_runner_bootstrap.ps1 仓库 scripts/ 现行唯一部署/体检入口(-Status / -DryRun / -SetEnv -StartNow) -Status、-SetEnv -StartNow 需要
check_env.ps1 D:\Docker\act_runner\ 环境自检(Gitea 可达 / 工具链 / MSVC / 任务状态) 否
run_runner_watchdog.ps1 D:\Docker\act_runner\ 看门狗启动器,由 bootstrap 生成,是计划任务的动作目标。纯 ASCII,不需要 BOM 由任务以 SYSTEM 跑
🗑 deploy_runner_task.ps1、run_runner.cmd D:\Docker\act_runner\ 已废弃(单次 daemon,§3.2)。仍在磁盘上,建议改名加 .deprecated 或删除,免得误执行把看门狗任务覆盖回单次任务 —

更早基于 Windows 服务的 deploy_runner.ps1 / install_service.ps1 已删除 —— 那条路走不通(§3.0)。


变更记录

(本文件不再记流水账:改了什么是正文说了算,改动过程查 git log。)