705 lines
34 KiB
PowerShell
705 lines
34 KiB
PowerShell
<#
|
|
.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 / DTU_MS_COVERAGE / DTU_SDL2_DIR. Existing values are preserved unless
|
|
-Force. MSVC discovery goes through vswhere (so VS on a non-default
|
|
drive is found) and pins the toolset with -MsvcToolset, because the
|
|
coverage baseline was recorded with one specific MSVC version -- see
|
|
the remote-CI guide (docs/CI/) section 4.4.
|
|
|
|
.PARAMETER StartNow
|
|
Start the task after registering it.
|
|
|
|
.PARAMETER Status
|
|
Only report: task state, daemon process, Gitea reachability.
|
|
|
|
.PARAMETER DryRun
|
|
Build and validate the action / trigger / settings objects and print what
|
|
WOULD be done, writing and registering nothing. This is also the only way
|
|
to verify this script without elevation.
|
|
|
|
.PARAMETER Force
|
|
With -SetEnv, overwrite existing machine-level values.
|
|
|
|
.PARAMETER MsvcToolset
|
|
Toolset version PREFIX to prefer when writing DTU_MSVC_CL (default
|
|
'14.51.'). 'newest available' is deliberately NOT the rule: the host
|
|
unit-test coverage baseline was measured with MSVC 14.51 and the
|
|
denominator changes with the compiler generation. When no toolset
|
|
matches the prefix the script warns and falls back to the newest.
|
|
|
|
.PARAMETER MsvcLatest
|
|
Ignore -MsvcToolset and take the newest MSVC toolset found.
|
|
|
|
.OUTPUTS
|
|
Progress lines. Exit codes: 0 = ok, 1 = precondition failed, 2 = not elevated.
|
|
|
|
.NOTES
|
|
Keep this file pure ASCII (PowerShell 5.1 decodes BOM-less files as CP936).
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[string]$RunnerDir = 'D:\Docker\act_runner',
|
|
[string]$GiteaUrl = 'http://127.0.0.1:3001',
|
|
[string]$TaskName = 'GiteaActionsRunner',
|
|
[switch]$SetEnv,
|
|
[switch]$StartNow,
|
|
[switch]$Status,
|
|
[switch]$DryRun,
|
|
[switch]$Force,
|
|
[string]$MsvcToolset = '14.51.',
|
|
[switch]$MsvcLatest
|
|
)
|
|
|
|
$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-VsInstances {
|
|
# VS install roots, newest first, via vswhere. This matters because
|
|
# vswhere knows about NON-DEFAULT INSTALL DRIVES: this machine keeps
|
|
# VS 18 BuildTools under E:, which a %ProgramFiles%-derived probe can
|
|
# never see. Returns an empty array when vswhere is unusable so the
|
|
# caller can fall back to the classic (incomplete) root scan.
|
|
$vswhere = $env:DTU_VSWHERE
|
|
if ([string]::IsNullOrWhiteSpace($vswhere)) {
|
|
$pf86 = [Environment]::GetFolderPath('ProgramFilesX86')
|
|
if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' }
|
|
$vswhere = Join-Path $pf86 'Microsoft Visual Studio\Installer\vswhere.exe'
|
|
}
|
|
if (-not (Test-Path -LiteralPath $vswhere)) { return @() }
|
|
$raw = & $vswhere -all -products * -format json 2>$null
|
|
if ($LASTEXITCODE -ne 0 -or -not $raw) { return @() }
|
|
$list = @()
|
|
foreach ($i in @($raw | ConvertFrom-Json)) {
|
|
if (-not $i -or -not $i.installationPath) { continue }
|
|
if (-not (Test-Path -LiteralPath $i.installationPath)) { continue }
|
|
$v = [version]'0.0'
|
|
if ($i.installationVersion) { [void][version]::TryParse([string]$i.installationVersion, [ref]$v) }
|
|
$list += [pscustomobject]@{ Root = [string]$i.installationPath; Version = $v }
|
|
}
|
|
return @($list | Sort-Object -Property Version -Descending)
|
|
}
|
|
|
|
function Find-MsvcClCandidates {
|
|
# Every x64-host cl.exe on this machine, tagged with its toolset version.
|
|
# Comparison operators in PowerShell are case-insensitive by default, so
|
|
# -match / -notmatch cover HostX64 / Hostx64 spelling differences.
|
|
$cands = @()
|
|
$roots = @(Find-VsInstances | ForEach-Object { $_.Root })
|
|
if ($roots.Count -eq 0) {
|
|
Warn 'vswhere unusable -- falling back to %ProgramFiles% roots; VS installs on other drives are INVISIBLE to that probe'
|
|
$pf = [Environment]::GetFolderPath('ProgramFiles')
|
|
$pf86 = [Environment]::GetFolderPath('ProgramFilesX86')
|
|
if ([string]::IsNullOrWhiteSpace($pf)) { $pf = 'C:\Program Files' }
|
|
if ([string]::IsNullOrWhiteSpace($pf86)) { $pf86 = 'C:\Program Files (x86)' }
|
|
foreach ($year in @('18', '2026', '2022', '2019')) {
|
|
foreach ($edition in @('BuildTools', 'Community', 'Professional', 'Enterprise')) {
|
|
foreach ($base in @($pf, $pf86)) {
|
|
$r = Join-Path $base ('Microsoft Visual Studio\' + $year + '\' + $edition)
|
|
if (Test-Path -LiteralPath $r) { $roots += $r }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
foreach ($r in $roots) {
|
|
$msvc = Join-Path $r 'VC\Tools\MSVC'
|
|
if (-not (Test-Path -LiteralPath $msvc)) { continue }
|
|
foreach ($f in (Get-ChildItem -LiteralPath $msvc -Recurse -Filter 'cl.exe' -ErrorAction SilentlyContinue)) {
|
|
$full = $f.FullName
|
|
if ($full -notmatch '\\bin\\Hostx64\\x64\\cl\.exe$') { continue }
|
|
$toolset = ''
|
|
if ($full -match '\\VC\\Tools\\MSVC\\([^\\]+)\\bin\\') { $toolset = $Matches[1] }
|
|
$ver = [version]'0.0'
|
|
[void][version]::TryParse($toolset, [ref]$ver)
|
|
$cands += [pscustomobject]@{ Cl = $full; Toolset = $toolset; Version = $ver; VsRoot = $r }
|
|
}
|
|
}
|
|
return @($cands)
|
|
}
|
|
|
|
function Find-MsvcCl {
|
|
# Rewritten 2026-10-27. The previous version walked a hard-coded root list
|
|
# derived from %ProgramFiles% and returned the FIRST hit: with VS2019
|
|
# (MSVC 14.29) and a newer VS both present it handed back 14.29, and it
|
|
# could not see the E: install at all. "First hit" is never right for a
|
|
# compiler: the coverage baseline (scripts/test_coverage_baseline.json)
|
|
# was recorded with MSVC 14.51 and the instrumented denominator moves when
|
|
# the compiler generation moves (measured: App/util/sha256.c is 105 lines
|
|
# under gcc vs 114 under MSVC -- see the host coverage policy doc, 2.1).
|
|
# Hence the default is a PINNED toolset prefix, not "newest".
|
|
param([string]$ToolsetPrefix = '', [switch]$Latest)
|
|
$cands = @(Find-MsvcClCandidates)
|
|
if ($cands.Count -eq 0) { return $null }
|
|
$sorted = @($cands | Sort-Object -Property Version -Descending)
|
|
$seen = (@($sorted | ForEach-Object { $_.Toolset } | Select-Object -Unique)) -join ', '
|
|
if (-not $Latest -and $ToolsetPrefix) {
|
|
$hit = @($sorted | Where-Object { $_.Toolset -like ($ToolsetPrefix + '*') })
|
|
if ($hit.Count -gt 0) {
|
|
Info ('MSVC pinned to toolset prefix ' + $ToolsetPrefix + '* -> ' + $hit[0].Toolset + ' (' + $hit.Count + ' of ' + $cands.Count + ' candidate(s); toolsets found: ' + $seen + ')')
|
|
return $hit[0].Cl
|
|
}
|
|
Warn ('no MSVC toolset matching prefix ' + $ToolsetPrefix + ' on this machine (found: ' + $seen + ')')
|
|
Warn 'falling back to the NEWEST toolset -- that changes the coverage denominator; read the remote-CI guide (docs/CI/) section 4.4 before accepting it'
|
|
}
|
|
Info ('using newest MSVC toolset ' + $sorted[0].Toolset + ' of ' + $cands.Count + ' candidate(s); toolsets found: ' + $seen)
|
|
return $sorted[0].Cl
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Locate an SDL2 SDK root usable by the UI simulator (ui-golden gate).
|
|
# The SDK is DOWNLOADED, not tracked: .gitignore excludes simulator/SDL2/, so a
|
|
# CI workspace never contains it. Measured 2026-10-27 run 45: ui-golden died with
|
|
# C1083 "SDL2/SDL.h" on the runner while every local run was green -- a missing
|
|
# third-party SDK, not a pixel regression. Discovery order:
|
|
# 1) an already-valid DTU_SDL2_DIR (Machine, then Process)
|
|
# 2) a machine-wide copy under -MachineSdkRoot (default D:\DTU_SDK)
|
|
# 3) this repository checkout's simulator/SDL2/SDL2-* -- works for local runs,
|
|
# but a runner workspace will not contain it, so on the CI box prefer 1 or 2
|
|
# Returns the root that contains include\SDL2\SDL.h, else $null.
|
|
# ---------------------------------------------------------------------------
|
|
function Find-Sdl2Sdk {
|
|
param([string]$MachineSdkRoot = 'D:\DTU_SDK')
|
|
$rel = 'include\SDL2\SDL.h'
|
|
$cand = [System.Collections.Generic.List[string]]::new()
|
|
foreach ($sc in @('Machine', 'User', 'Process')) {
|
|
$v = [Environment]::GetEnvironmentVariable('DTU_SDL2_DIR', $sc)
|
|
if ($v) { $cand.Add($v) }
|
|
}
|
|
if ($MachineSdkRoot -and (Test-Path -LiteralPath $MachineSdkRoot)) {
|
|
Get-ChildItem -LiteralPath $MachineSdkRoot -Directory -Filter 'SDL2-*' -ErrorAction SilentlyContinue |
|
|
Sort-Object Name -Descending | ForEach-Object { $cand.Add($_.FullName) }
|
|
}
|
|
$repoSdk = Join-Path (Split-Path -Parent $PSScriptRoot) 'simulator\SDL2'
|
|
if (Test-Path -LiteralPath $repoSdk) {
|
|
Get-ChildItem -LiteralPath $repoSdk -Directory -Filter 'SDL2-*' -ErrorAction SilentlyContinue |
|
|
Sort-Object Name -Descending | ForEach-Object { $cand.Add($_.FullName) }
|
|
}
|
|
foreach ($c in $cand) {
|
|
if ($c -and (Test-Path -LiteralPath (Join-Path $c $rel))) { return $c }
|
|
}
|
|
return $null
|
|
}
|
|
function Find-CoverageConsole {
|
|
# Microsoft.CodeCoverage.Console.exe ships ONLY inside Visual Studio
|
|
# (measured 2026-10-27: NuGet candidates contain no such .exe, or only the
|
|
# legacy Team Tools CodeCoverage.exe whose CLI differs). Its location
|
|
# inside a VS install is a fixed relative path:
|
|
param([string]$ClPath = '')
|
|
$rel = 'Common7\IDE\Extensions\Microsoft\CodeCoverage.Console\Microsoft.CodeCoverage.Console.exe'
|
|
$roots = @()
|
|
if ($ClPath) {
|
|
$vsRoot = ''
|
|
# cl.exe = <root>\VC\Tools\MSVC\<ver>\bin\Hostx64\x64\cl.exe
|
|
if ($ClPath -match '^(.*)\\VC\\Tools\\MSVC\\') { $vsRoot = $Matches[1] }
|
|
if ($vsRoot) { $roots += $vsRoot }
|
|
}
|
|
foreach ($i in @(Find-VsInstances)) { $roots += $i.Root }
|
|
foreach ($r in ($roots | Where-Object { $_ } | Select-Object -Unique)) {
|
|
$cand = Join-Path $r $rel
|
|
if (Test-Path -LiteralPath $cand) { return $cand }
|
|
}
|
|
return $null
|
|
}
|
|
|
|
# Builds the task objects in ONE place so -DryRun validates exactly what the
|
|
# real run registers.
|
|
#
|
|
# MEASURED TRAP (2026-10-11, hit on the user's machine): `StartWhenAvailable`
|
|
# is a TASK SETTINGS property (MSFT_TaskSettings, inherited by
|
|
# MSFT_TaskSettings3) -- it does NOT exist on triggers. Assigning it to the
|
|
# trigger aborts the script with a SetValueInvocationException along the lines
|
|
# of "setting StartWhenAvailable threw an exception: property
|
|
# 'StartWhenAvailable' is not found on this object" (message localized),
|
|
# and, worse, that happened AFTER the old task had already been unregistered,
|
|
# leaving CI with no task at all. Hence: build everything first, only then
|
|
# unregister the old task.
|
|
function New-WatchdogTaskPlan {
|
|
param([string]$Launcher, [string]$WorkDir)
|
|
|
|
$action = New-ScheduledTaskAction `
|
|
-Execute 'powershell.exe' `
|
|
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$Launcher`"" `
|
|
-WorkingDirectory $WorkDir
|
|
|
|
# Boot trigger + a 1 minute grace period: Gitea may still be starting when
|
|
# the session-0 boot fires (and the watchdog waits for it anyway).
|
|
$trigger = New-ScheduledTaskTrigger -AtStartup
|
|
$trigger.Delay = 'PT1M'
|
|
|
|
$settings = New-ScheduledTaskSettingsSet `
|
|
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
|
-ExecutionTimeLimit ([TimeSpan]::Zero) `
|
|
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
|
|
-MultipleInstances IgnoreNew
|
|
# StartWhenAvailable also covers "the machine was off when this was missed".
|
|
$settings.StartWhenAvailable = $true
|
|
|
|
return [pscustomobject]@{ Action = $action; Trigger = $trigger; Settings = $settings }
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# -Status: read-only report
|
|
# ---------------------------------------------------------------------------
|
|
if ($Status) {
|
|
Info "task $TaskName"
|
|
$t = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
if ($t) {
|
|
Ok ("task state: {0}" -f $t.State)
|
|
$li = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
if ($li) {
|
|
Info ("last run: {0} | last result: {1} | next run: {2}" -f `
|
|
$li.LastRunTime, ('0x{0:X}' -f $li.LastTaskResult), $li.NextRunTime)
|
|
}
|
|
}
|
|
else {
|
|
Warn "task $TaskName not found"
|
|
if (-not (Test-Admin)) {
|
|
Warn ' this session is NOT elevated: a non-admin session cannot always see'
|
|
Warn ' every task, so re-run -Status elevated before concluding it is missing'
|
|
}
|
|
}
|
|
|
|
$proc = @(Get-Process | Where-Object { $_.ProcessName -match 'gitea-runner|act_runner' })
|
|
if ($proc.Count -eq 1) {
|
|
Ok ("daemon running: pid {0}" -f $proc[0].Id)
|
|
} elseif ($proc.Count -gt 1) {
|
|
Warn (" {0} daemon processes -- only ONE can hold .runner.lock, the rest are in a restart loop" -f $proc.Count)
|
|
Warn ' fix: re-run this script elevated with -StartNow (it clears stale instances first)'
|
|
} else { Warn 'daemon not running' }
|
|
|
|
# A watchdog that cannot get the lock fails every 10s while the runner still
|
|
# reports ONLINE, so the loop is invisible from the web UI. Count watchdogs and
|
|
# grep the daemon log for the lock error (measured 2026-10-12).
|
|
# MEASURED TRAP #3 (2026-10-12): a NON-ELEVATED session cannot read SYSTEM
|
|
# process command lines -- CommandLine comes back $null, so the filter below
|
|
# silently matches NOTHING and reporting "no watchdog" would be a lie (this
|
|
# bit us twice while diagnosing). Say what we cannot see instead.
|
|
if (-not (Test-Admin)) {
|
|
Info ' watchdog count skipped: needs elevation (SYSTEM command lines are hidden)'
|
|
} else {
|
|
$wd = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.CommandLine -match 'run_runner_watchdog\.ps1' })
|
|
if ($wd.Count -gt 1) {
|
|
Warn (" {0} watchdog processes (pids {1}) -- an old one survived a task re-registration" -f `
|
|
$wd.Count, (($wd | ForEach-Object { $_.ProcessId }) -join ','))
|
|
Warn ' and is racing the new one for .runner.lock; re-run elevated with -StartNow'
|
|
} elseif ($wd.Count -eq 1) {
|
|
Ok ("watchdog running: pid {0}" -f $wd[0].ProcessId)
|
|
} else {
|
|
Warn 'no watchdog process found (task not running?)'
|
|
}
|
|
}
|
|
# Restart-loop detection. Do NOT just grep the log tail: the file is never
|
|
# rotated, so old failures stay in the last lines forever and a HEALTHY runner
|
|
# keeps "reporting" a stale error (measured false positive 2026-10-12). The
|
|
# reliable signal is a daemon that keeps dying young -- a process alive longer
|
|
# than the 10s restart interval cannot be inside that loop. CIM CreationDate is
|
|
# readable without elevation; Process.StartTime is NOT for SYSTEM processes.
|
|
$dmon = Get-CimInstance Win32_Process -Filter "Name='gitea-runner.exe'" -ErrorAction SilentlyContinue |
|
|
Select-Object -First 1
|
|
$upSec = if ($dmon) { [int]((Get-Date) - $dmon.CreationDate).TotalSeconds } else { -1 }
|
|
if ($upSec -ge 90) {
|
|
Ok ("daemon up for {0} min -- no restart loop" -f [int]($upSec / 60))
|
|
} else {
|
|
$dlog = Join-Path $RunnerDir 'log\runner-daemon.log'
|
|
if (Test-Path -LiteralPath $dlog) {
|
|
$tail = Get-Content -LiteralPath $dlog -Tail 40 -Encoding UTF8 -ErrorAction SilentlyContinue
|
|
if ($tail -match 'already locked by another process') {
|
|
Warn ' daemon keeps dying young and the log shows ".runner is already locked'
|
|
Warn ' by another process" -- a stale instance holds the lock; re-run with -StartNow'
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Test-Gitea $GiteaUrl) { Ok "Gitea reachable at $GiteaUrl" }
|
|
else { Fail "Gitea NOT reachable at $GiteaUrl" }
|
|
|
|
foreach ($n in @('DTU_PRECOMMIT_PYTHON', 'DTU_CPPCHECK', 'DTU_MSVC_CL', 'DTU_MS_COVERAGE', 'DTU_SDL2_DIR')) {
|
|
$v = Get-MachineEnv $n
|
|
if ($v) { Ok ("MACHINE {0} = {1}" -f $n, $v) }
|
|
else { Warn "MACHINE $n not set (CI auto-discovery still applies; -SetEnv writes it, docs/CI/ guide sections 4.4 / 4.5 explain the pairing)" }
|
|
}
|
|
exit 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preconditions
|
|
# ---------------------------------------------------------------------------
|
|
if (-not $DryRun -and -not (Test-Admin)) {
|
|
Fail 'not elevated -- rerun from an elevated PowerShell (this writes a scheduled task and machine env vars)'
|
|
Fail 'use -DryRun to validate the whole plan (action/trigger/settings) without elevation'
|
|
exit 2
|
|
}
|
|
|
|
$exe = Join-Path $RunnerDir 'gitea-runner.exe'
|
|
$cfg = Join-Path $RunnerDir 'config.yaml'
|
|
$regFile = Join-Path $RunnerDir '.runner'
|
|
$launcher = Join-Path $RunnerDir 'run_runner_watchdog.ps1'
|
|
|
|
foreach ($f in @($exe, $cfg)) {
|
|
if (-not (Test-Path -LiteralPath $f)) { Fail "missing file: $f"; exit 1 }
|
|
}
|
|
if (-not (Test-Path -LiteralPath $regFile)) {
|
|
Fail "not registered: $regFile is missing"
|
|
Info 'register first, e.g.:'
|
|
Write-Host " & '$exe' register --no-interactive --instance '$GiteaUrl' --token <RegistrationToken> --name win-stm32-ci --labels 'windows:host'"
|
|
exit 1
|
|
}
|
|
|
|
# The daemon must talk to the address baked into .runner, not the public URL.
|
|
$registeredAddress = $null
|
|
try {
|
|
$registeredAddress = (Get-Content -LiteralPath $regFile -Raw | ConvertFrom-Json).address
|
|
}
|
|
catch { Warn "cannot parse $regFile : $_" }
|
|
if ($registeredAddress -and $registeredAddress.TrimEnd('/') -ne $GiteaUrl.TrimEnd('/')) {
|
|
Warn ".runner address is $registeredAddress but -GiteaUrl is $GiteaUrl -- using the .runner value for the readiness probe"
|
|
$GiteaUrl = $registeredAddress
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 0. build the task plan up front; -DryRun stops here without writing anything
|
|
# ---------------------------------------------------------------------------
|
|
$plan = New-WatchdogTaskPlan -Launcher $launcher -WorkDir $RunnerDir
|
|
|
|
if ($DryRun) {
|
|
Info 'dry run -- writing and registering NOTHING'
|
|
Info ("would write launcher : {0}" -f $launcher)
|
|
Info ("would run : {0} {1}" -f $plan.Action.Execute, $plan.Action.Arguments)
|
|
Info ("would trigger : {0} delay={1}" -f $plan.Trigger.CimClass.CimClassName, $plan.Trigger.Delay)
|
|
Info ("would settings : StartWhenAvailable={0} RestartCount={1} RestartInterval={2} ExecTimeLimit={3} MultipleInstances={4}" -f `
|
|
$plan.Settings.StartWhenAvailable, $plan.Settings.RestartCount, $plan.Settings.RestartInterval,
|
|
$plan.Settings.ExecutionTimeLimit, $plan.Settings.MultipleInstances)
|
|
Info 'would register as : user SYSTEM, RunLevel Highest'
|
|
if (Test-Admin) { Ok 'elevation: OK (admin)' }
|
|
else { Warn 'elevation: NOT admin -- the real run must be started from an elevated shell' }
|
|
if (Test-Gitea $GiteaUrl) { Ok "Gitea reachable at $GiteaUrl" }
|
|
else { Warn "Gitea not reachable at $GiteaUrl -- the watchdog would keep waiting" }
|
|
exit 0
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. machine-level toolchain (SYSTEM cannot see the interactive user's PATH)
|
|
# ---------------------------------------------------------------------------
|
|
if ($SetEnv) {
|
|
Info 'discovering toolchain for MACHINE scope ...'
|
|
|
|
$findPy = Join-Path $PSScriptRoot 'ci_find_python.ps1'
|
|
if (Test-Path -LiteralPath $findPy) {
|
|
$py = & $findPy -NeedPreCommit
|
|
if ($LASTEXITCODE -eq 0 -and $py) {
|
|
Set-MachineEnv 'DTU_PRECOMMIT_PYTHON' ("$py".Trim()) 'DTU_PRECOMMIT_PYTHON'
|
|
}
|
|
else {
|
|
Warn 'no interpreter with pre-commit found -- install pre-commit into a machine-wide interpreter first'
|
|
}
|
|
}
|
|
else { Warn "not found: $findPy (skipping DTU_PRECOMMIT_PYTHON)" }
|
|
|
|
$cpp = Find-Cppcheck
|
|
if ($cpp) { Set-MachineEnv 'DTU_CPPCHECK' $cpp 'DTU_CPPCHECK' }
|
|
else { Warn 'cppcheck not found (install Cppcheck.Cppcheck, or set DTU_CPPCHECK manually)' }
|
|
|
|
# ui-golden needs the SDL2 SDK and a checked-out tree never has it (see
|
|
# Find-Sdl2Sdk). Pin it machine-wide so the runner resolves it under SYSTEM too.
|
|
$sdl = Find-Sdl2Sdk
|
|
if ($sdl) {
|
|
Set-MachineEnv 'DTU_SDL2_DIR' $sdl 'DTU_SDL2_DIR'
|
|
$repoRoot = (Split-Path -Parent $PSScriptRoot) + [IO.Path]::DirectorySeparatorChar
|
|
if ($sdl.StartsWith($repoRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
|
Warn 'DTU_SDL2_DIR points INSIDE this repo checkout -- a runner workspace will not contain that path.'
|
|
Warn 'copy an SDK to D:\DTU_SDK (or point DTU_SDL2_DIR at a machine-wide path), then re-run -SetEnv'
|
|
}
|
|
}
|
|
else {
|
|
Warn 'no SDL2 SDK found: the ui-golden gate fails with C1083 SDL2/SDL.h (measured run 45).'
|
|
Warn 'download it per the simulator deployment guide (docs/CI/ guide, section 4.5) or copy one to D:\DTU_SDK, then re-run -SetEnv'
|
|
}
|
|
|
|
$cl = Find-MsvcCl -ToolsetPrefix $MsvcToolset -Latest:$MsvcLatest
|
|
if ($cl) {
|
|
Set-MachineEnv 'DTU_MSVC_CL' $cl 'DTU_MSVC_CL'
|
|
# Pin the coverage collector too, and pin it INSIDE the same VS as the
|
|
# chosen cl.exe: the gate derives it from the compiler ancestor anyway,
|
|
# but an explicit machine value also covers the case where somebody
|
|
# later points DTU_MSVC_CL at another install. Both vars must be
|
|
# changed together (docs/CI/ guide, section 4.4).
|
|
$cov = Find-CoverageConsole -ClPath $cl
|
|
if ($cov) { Set-MachineEnv 'DTU_MS_COVERAGE' $cov 'DTU_MS_COVERAGE' }
|
|
else {
|
|
Warn 'Microsoft.CodeCoverage.Console.exe NOT found -- the test-coverage gate FAILS with [ENV] exit 2 (by the 2026-10-24 ruling it does not skip).'
|
|
Warn 'install VS BuildTools 17.9+/18.x including Microsoft.VisualStudio.PackageGroup.TestTools.CodeCoverage, then re-run -SetEnv'
|
|
}
|
|
}
|
|
else { Warn 'MSVC cl.exe (Hostx64\x64) not found -- L0.5 will degrade to "skipped" without it' }
|
|
|
|
Info 'machine env vars are read when a PROCESS STARTS: the daemon must be restarted below'
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. watchdog launcher (the actual fix for "daemon exits and nobody notices")
|
|
# ---------------------------------------------------------------------------
|
|
Info "writing watchdog launcher: $launcher"
|
|
|
|
$template = @'
|
|
# AUTO-GENERATED by scripts/ci_runner_bootstrap.ps1 -- do not edit by hand.
|
|
#
|
|
# gitea-runner `daemon` exits when its first Declare call fails (no retry, no
|
|
# service support in v4), so a plain scheduled task is NOT enough: a Gitea
|
|
# restart would take CI down until a human noticed. This loop
|
|
# 1) waits until Gitea answers, then
|
|
# 2) runs the daemon, and
|
|
# 3) restarts it after a short pause whenever it exits.
|
|
# Keep this file pure ASCII (PowerShell 5.1 decodes BOM-less files as CP936).
|
|
$ErrorActionPreference = 'Continue'
|
|
|
|
$runnerDir = '__RUNNER_DIR__'
|
|
$runnerExe = Join-Path $runnerDir 'gitea-runner.exe'
|
|
$runnerCfg = Join-Path $runnerDir 'config.yaml'
|
|
$logDir = Join-Path $runnerDir 'log'
|
|
$log = Join-Path $logDir 'runner-watchdog.log'
|
|
$daemonLog = Join-Path $logDir 'runner-daemon.log'
|
|
$probeUrl = '__GITEA_URL__/api/v1/version'
|
|
$maxWaitSec = 1800
|
|
|
|
if (-not (Test-Path -LiteralPath $logDir)) {
|
|
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
|
}
|
|
|
|
function Log($m) {
|
|
$line = '{0} [watchdog] {1}' -f (Get-Date -Format 'o'), $m
|
|
Add-Content -LiteralPath $log -Value $line -Encoding ascii
|
|
}
|
|
|
|
while ($true) {
|
|
$waited = 0
|
|
while ($true) {
|
|
$ok = $false
|
|
try {
|
|
Invoke-WebRequest -Uri $probeUrl -UseBasicParsing -TimeoutSec 10 | Out-Null
|
|
$ok = $true
|
|
}
|
|
catch { $ok = $false }
|
|
if ($ok) { break }
|
|
if ($waited -eq 0) { Log "waiting for Gitea at $probeUrl" }
|
|
Start-Sleep -Seconds 15
|
|
$waited += 15
|
|
if ($waited -ge $maxWaitSec) {
|
|
Log 'Gitea still not answering after 30m -- starting anyway so the real error lands in the log'
|
|
break
|
|
}
|
|
}
|
|
|
|
Log 'starting daemon'
|
|
# MEASURED TRAP (2026-10-12, hit by this very script): in PowerShell 5.1 the
|
|
# *>> redirection is implemented with Out-File, whose DEFAULT ENCODING IS
|
|
# UTF-16LE. Appending that into a file whose other lines come from
|
|
# Add-Content -Encoding ascii leaves ONE FILE WITH TWO ENCODINGS, and the
|
|
# daemon lines read back as "t i m e = 2 0 2 6 ..." (a NUL after every byte).
|
|
# Never rely on a redirection's default encoding -- state it explicitly.
|
|
# utf8 (not ascii) because job/commit text can legitimately be non-ASCII.
|
|
# MEASURED TRAP #2 (2026-10-12): the daemon writes its log to STDERR, and in
|
|
# PS 5.1 a native `2>&1` merge wraps every stderr line into an ErrorRecord.
|
|
# Out-File then renders "gitea-runner.exe : <line>" plus a 4-line CategoryInfo
|
|
# block -- measured: a 2-line message became 8 lines, so 3 of every 4 lines in
|
|
# the daemon log were PowerShell noise, in the very file the docs tell people
|
|
# to read first. Stringifying each pipeline item keeps the text and drops the
|
|
# decoration (ErrorRecord.ToString() IS the original stderr line) and leaves
|
|
# $LASTEXITCODE intact. Verified against a cmd.exe wrapper; same result, and
|
|
# this form avoids cmd's quote-stripping rules for spaced paths.
|
|
& $runnerExe daemon --config $runnerCfg 2>&1 |
|
|
ForEach-Object { "$_" } |
|
|
Out-File -FilePath $daemonLog -Append -Encoding utf8
|
|
Log ("daemon exited, exit code {0}; restarting in 10s" -f $LASTEXITCODE)
|
|
Start-Sleep -Seconds 10
|
|
}
|
|
'@
|
|
|
|
$content = $template.Replace('__RUNNER_DIR__', $RunnerDir).Replace('__GITEA_URL__', $GiteaUrl.TrimEnd('/'))
|
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
[System.IO.File]::WriteAllText($launcher, $content, $utf8NoBom)
|
|
Ok 'launcher written'
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. scheduled task: start at boot, run as SYSTEM, restart if it dies
|
|
# ---------------------------------------------------------------------------
|
|
$existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
|
if ($existing) {
|
|
Info "task $TaskName exists -- unregistering to re-register with the current settings"
|
|
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
|
}
|
|
|
|
try {
|
|
Register-ScheduledTask -TaskName $TaskName -Action $plan.Action -Trigger $plan.Trigger `
|
|
-Settings $plan.Settings -User 'SYSTEM' -RunLevel Highest `
|
|
-Description 'Gitea Actions runner watchdog (STM32F4-Base CI). Supervises the gitea-runner daemon: waits for Gitea, restarts the daemon when it exits. Generated by scripts/ci_runner_bootstrap.ps1.' |
|
|
Out-Null
|
|
}
|
|
catch {
|
|
Fail "Register-ScheduledTask failed: $($_.Exception.Message)"
|
|
if ($existing) {
|
|
Fail "the previous task was ALREADY removed -- fix the cause and re-run this script immediately"
|
|
}
|
|
Fail 'until this succeeds the runner has no auto-start and CI stays dead'
|
|
exit 1
|
|
}
|
|
Ok "scheduled task registered: $TaskName (at boot +1m, as SYSTEM, unlimited runtime, restart x999/1m)"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. start it -- but clear stale instances first, or the new watchdog never gets
|
|
# the .runner lock.
|
|
#
|
|
# MEASURED TRAP (2026-10-12): Unregister-ScheduledTask does NOT stop the process
|
|
# the old instance had started. That orphaned watchdog keeps its daemon alive and
|
|
# the daemon holds .runner.lock, so the freshly started watchdog fails every 10s:
|
|
# level=error msg="another gitea-runner process is already using \".runner\"..."
|
|
# Error: runner file is already locked by another process
|
|
# The runner still shows ONLINE (the stale daemon serves fine), so CI looks healthy
|
|
# while the log fills with a restart loop and the task supervises nothing at all.
|
|
# ---------------------------------------------------------------------------
|
|
function Stop-StaleRunnerInstances {
|
|
# Supervisors first: killing the daemon alone lets a live watchdog respawn it
|
|
# within 10s and the race starts over.
|
|
$killed = 0
|
|
Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.CommandLine -match 'run_runner_watchdog\.ps1' } |
|
|
ForEach-Object {
|
|
Info (" stopping stale watchdog pid {0}" -f $_.ProcessId)
|
|
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
|
$killed++
|
|
}
|
|
Get-Process -Name 'gitea-runner' -ErrorAction SilentlyContinue | ForEach-Object {
|
|
Info (" stopping stale daemon pid {0} (releases .runner.lock)" -f $_.Id)
|
|
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
|
|
$killed++
|
|
}
|
|
return $killed
|
|
}
|
|
|
|
if ($StartNow) {
|
|
if (-not (Test-Gitea $GiteaUrl)) {
|
|
Warn "Gitea is not answering at $GiteaUrl right now -- the watchdog will keep waiting"
|
|
}
|
|
$stale = Stop-StaleRunnerInstances
|
|
if ($stale -gt 0) {
|
|
Warn "$stale stale process(es) stopped; the runner is offline for a few seconds"
|
|
Start-Sleep -Seconds 2
|
|
}
|
|
Start-ScheduledTask -TaskName $TaskName
|
|
Start-Sleep -Seconds 12 # the watchdog probes Gitea, then execs the daemon
|
|
$d = @(Get-Process -Name 'gitea-runner' -ErrorAction SilentlyContinue)
|
|
if ($d.Count -eq 1) {
|
|
Ok ("daemon up: pid {0} (single instance, holds .runner.lock)" -f $d[0].Id)
|
|
} elseif ($d.Count -eq 0) {
|
|
Warn 'no daemon yet -- the watchdog may still be waiting for Gitea; check the watchdog log'
|
|
} else {
|
|
Warn ("{0} daemons running -- only one can hold the lock; inspect and kill the extras" -f $d.Count)
|
|
}
|
|
Ok "daemon log: Get-Content '$(Join-Path $RunnerDir 'log\runner-daemon.log')' -Tail 20"
|
|
Ok "watchdog log: Get-Content '$(Join-Path $RunnerDir 'log\runner-watchdog.log')' -Tail 20"
|
|
Info 'verify on the web UI: repo -> Settings -> Actions -> Runners -> win-stm32-ci should turn Idle'
|
|
}
|
|
|
|
Ok 'done'
|
|
exit 0
|