From ff136862ad5be07952be5296224a541e813cf01e Mon Sep 17 00:00:00 2001 From: Riccardo Persello Date: Mon, 7 Sep 2026 18:29:14 +0200 Subject: [PATCH 01/13] feat(windows): provision a native Windows runner Linux runs in Docker; Windows cannot. Windows containers do not run on the ARM64 Parallels VM this targets, so a Windows runner is provisioned natively onto a machine that is set up once and kept. provision.ps1 is idempotent, so re-running it is how a machine is upgraded rather than only how one is built. Registration mirrors entrypoint.sh: a PAT mints a short-lived registration token on every run, and the same five stale state files are cleared before reconfiguring -- `.runner_migrated` included, since config.cmd treats that marker alone as proof the runner is already configured and leaving it behind is what silently took the Linux fleet offline after a self-update. The job hooks are PowerShell twins of the .sh ones and exist for the same reasons: an accumulating .gitconfig collides with a later `git config set`, and target/ drifts until the disk fills. The Linux entrypoint exports the cargo knobs before run.sh; a Windows service has no equivalent and the runner's .env is read only by the Linux systemd unit, so these are set as machine-level environment and the service is restarted to pick them up. Two constraints are enforced in code rather than left to documentation. The service account may not be LocalSystem: tauri's NSIS cache resolves inside systemprofile where the download lands nowhere it later looks (surfacing as a misleading `error 0x2`, which is ERROR_FILE_NOT_FOUND), and node_modules created by a SYSTEM build blocks every later build under another account. And config.cmd is invoked WITHOUT --unattended so it prompts for the account password itself, keeping it out of this process's command line. Runners are labelled by what the machine is, not what it builds. An ARM64 Windows box cross-compiles x86_64-pc-windows-msvc fine -- verified end to end, including an NSIS installer whose payload is PE machine 0x8664 -- so labelling it windows-x64 would break the first time a real x64 machine joins. CARGO_BUILD_JOBS defaults to half the CPUs: this VM shares a host, and an unthrottled Windows build starves the OrbStack fleet into "lost communication". Not yet executed anywhere. The target VM was powered off while this was written, so the scripts are unrun and their syntax unverified. Co-Authored-By: Claude Opus 5 --- .gitattributes | 7 +- README.md | 88 ++++++- windows/job-completed-hook.ps1 | 73 ++++++ windows/job-started-hook.ps1 | 37 +++ windows/provision.ps1 | 422 +++++++++++++++++++++++++++++++++ 5 files changed, 624 insertions(+), 3 deletions(-) create mode 100644 windows/job-completed-hook.ps1 create mode 100644 windows/job-started-hook.ps1 create mode 100644 windows/provision.ps1 diff --git a/.gitattributes b/.gitattributes index fe8fe8e..064c716 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,7 @@ # Force all .sh files to use LF (Unix) line endings -*.sh text eol=lf \ No newline at end of file +*.sh text eol=lf +# PowerShell and batch are Windows-native; keep CRLF so they read correctly +# in Notepad and are byte-identical to what is executed on the runner. +*.ps1 text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/README.md b/README.md index e4aa516..e8f6661 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# Dockerized self-hosted GitHub Runner +# Self-hosted GitHub Runners -A self-hosted GitHub Actions runner Docker image configured for JKU Racing firmware development. +Self-hosted GitHub Actions runners configured for JKU Racing firmware +development: a Docker image for Linux (`amd64`/`arm64`), and a native +PowerShell provisioning script for Windows (see [Windows runners](#windows-runners)). ## Pre-installed Tools @@ -131,3 +133,85 @@ docker compose up -d --build > Always pass `--build`. Plain `docker compose up -d` only builds when the image > is missing, so it will happily keep running a stale image after the Dockerfile > or `entrypoint.sh` changes. + +## Windows runners + +Linux runs in Docker; Windows does not. Windows containers cannot run on the +ARM64 Parallels VM this targets, so a Windows runner is provisioned natively +onto a machine that is set up once and kept. `windows/provision.ps1` is that +provisioning, and it is idempotent -- re-running it upgrades the toolchain and +re-registers against a freshly minted token, which is the intended way to +update a machine rather than only to build one. + +```powershell +# Elevated PowerShell, on the machine that will run jobs. +$env:GITHUB_PAT = '' +.\windows\provision.ps1 -ServiceAccount '.\ci' +``` + +### The service account is not optional, and must not be SYSTEM + +`config.cmd` prompts for the account's password itself, so it never reaches a +command line, an environment variable, or this repo. Create the account first +(you choose the password); the script refuses to invent one: + +```powershell +New-LocalUser -Name 'ci' -Description 'GitHub Actions runner' -PasswordNeverExpires +``` + +Running jobs as LocalSystem is rejected outright, because two independent +things break under it and both were found the hard way: + +- tauri caches its NSIS toolchain under `%LOCALAPPDATA%\tauri\NSIS`. Under + SYSTEM that resolves inside `systemprofile`, the download reports success, + nothing lands, and the bundler dies with `Unable to start child process, + error 0x2` -- which is `ERROR_FILE_NOT_FOUND`, not the x86-emulation failure + it reads as. +- `node_modules` created by a SYSTEM build is owned by SYSTEM, and any later + build under another account hangs or fails `EPERM` on it. + +### Architecture + +The runner is labelled by what the machine **is** (`windows-arm64` or +`windows-x64`), not by what it builds. An ARM64 Windows box cross-compiles +`x86_64-pc-windows-msvc` perfectly well -- verified end to end, including an +NSIS installer whose payload is PE machine `0x8664` -- so labelling an ARM64 +machine `windows-x64` would be a lie that breaks the first time a real x64 +machine joins. + +`makensis.exe` is a 32-bit x86 binary and runs under ARM64's emulation, the +same way the amd64-only `pkl` this toolchain installs does. Nothing about the +Windows packaging path requires an x64 host. + +### Toolchain + +Established empirically against hbf rather than from vendor docs: + +| Tool | Why | +|------|-----| +| Git for Windows | **Required.** Every composite action these workflows use declares `shell: bash`, which resolves to `bash.exe` on PATH. Without it the runner registers and then fails every job. | +| VS Build Tools | The MSVC linker. `*-pc-windows-msvc` cannot link without it. | +| Rust + both MSVC targets | Either direction of cross-compilation from one machine. | +| `cargo-nextest` | hbf's suite needs it; plain `cargo test` produces phantom 30s timeouts. | +| clang (LLVM) | **ARM64 only** -- `ring` assembles its crypto with it there. x64 links with MSVC alone. | +| Pkl | A build script shells out to it. No ARM64 build exists; the amd64 exe runs emulated. | +| bun | `hbf-gui`'s `generate_context!` embeds `ui/build` at *compile* time. | +| WebView2 | Preinstalled on Windows 11; checked, not assumed. | + +`winget` is deliberately unused -- it hangs under a non-interactive remote +session on this VM, so every install is `curl` plus a silent installer. + +### Hooks and machine environment + +`windows/job-started-hook.ps1` and `windows/job-completed-hook.ps1` are the +PowerShell twins of the `.sh` hooks, for the same reasons: resetting an +accumulating `.gitconfig` before each job, and bounding `target/` after it. +The Linux entrypoint exports the cargo knobs before `run.sh`; a Windows service +has no equivalent hook and the runner's `.env` is read only by the Linux +systemd unit, so `provision.ps1` sets them as **machine-level** environment and +restarts the service to pick them up. + +`CARGO_BUILD_JOBS` defaults to half the CPUs rather than all of them. This VM +is expected to share a host with other work, and an unthrottled Windows build +starves the OrbStack Linux fleet badly enough that its runners drop with "lost +communication". diff --git a/windows/job-completed-hook.ps1 b/windows/job-completed-hook.ps1 new file mode 100644 index 0000000..13344a6 --- /dev/null +++ b/windows/job-completed-hook.ps1 @@ -0,0 +1,73 @@ +<# + Runs after EVERY job, via ACTIONS_RUNNER_HOOK_JOB_COMPLETED. + + The PowerShell twin of job-completed-hook.sh: bound `target/` between jobs so + a persistent machine does not drift until the disk fills. On the Linux fleet + that surfaced not as "out of disk" but as a linker bus error, which cost real + time to diagnose; a Windows machine will fail differently but no more + clearly. + + The budget is higher here than the fleet's 4 GB because this is ONE machine + rather than twelve replicas sharing a volume, and because a Windows build + tree carries both the host and the cross target -- hbf builds + aarch64-pc-windows-msvc and x86_64-pc-windows-msvc from one checkout. + + Why a job hook rather than a scheduled task: the runner invokes this between + jobs, so it can never delete a target/ out from under a live compile. + + This bounds STEADY STATE, not the peak. A build in flight can exceed the + threshold and is only swept once it finishes. +#> +$ErrorActionPreference = 'Continue' + +# Written the long way rather than with `??`: the runner may invoke this hook +# with Windows PowerShell 5.1, which has no null-coalescing operator and would +# fail to parse the file outright. +$maxGb = if ($env:SWEEP_MAX_GB) { [int]$env:SWEEP_MAX_GB } else { 8 } +# The runner root's _work, NOT RUNNER_WORKSPACE. RUNNER_WORKSPACE is +# per-repository, so using it would enforce the budget once per repo rather +# than once per machine -- on the Linux fleet that let each replica hold twice +# its nominal budget with two repos checked out. +$workDir = if ($env:SWEEP_WORK_DIR) { $env:SWEEP_WORK_DIR } else { 'C:\actions-runner\_work' } +if (-not (Test-Path $workDir)) { exit 0 } + +function Get-SizeMb($Path) { + try { + [Math]::Round((Get-ChildItem -LiteralPath $Path -Recurse -Force -File -ErrorAction SilentlyContinue | + Measure-Object -Property Length -Sum).Sum / 1MB) + } catch { 0 } +} + +$usedMb = Get-SizeMb $workDir +$limitMb = $maxGb * 1024 +if ($usedMb -le $limitMb) { + Write-Host "sweep: _work at $usedMb MB, under the $limitMb MB budget -- keeping it warm" + exit 0 +} + +Write-Host "sweep: _work at $usedMb MB exceeds $limitMb MB -- removing target dirs" + +# Largest first, stopping as soon as the budget is met, so the machine keeps as +# much warmth as the budget allows instead of being emptied wholesale. Only +# genuine cargo target dirs are touched: the CACHEDIR.TAG / debug / release +# test avoids deleting a source directory that merely happens to be named +# "target". +$targets = Get-ChildItem -LiteralPath $workDir -Recurse -Directory -Force -Filter 'target' -ErrorAction SilentlyContinue | + Where-Object { + (Test-Path (Join-Path $_.FullName 'CACHEDIR.TAG')) -or + (Test-Path (Join-Path $_.FullName 'debug')) -or + (Test-Path (Join-Path $_.FullName 'release')) + } | + ForEach-Object { [pscustomobject]@{ Path = $_.FullName; Mb = Get-SizeMb $_.FullName } } | + Sort-Object Mb -Descending + +foreach ($t in $targets) { + if ($usedMb -le $limitMb) { break } + Remove-Item -LiteralPath $t.Path -Recurse -Force -ErrorAction SilentlyContinue + $usedMb -= $t.Mb + Write-Host "sweep: removed $($t.Path) ($($t.Mb) MB), now ~$usedMb MB" +} + +# Never fail the job: this runs after the work that matters is already done and +# reported, and a sweep problem must not turn a green job red. +exit 0 diff --git a/windows/job-started-hook.ps1 b/windows/job-started-hook.ps1 new file mode 100644 index 0000000..a841bf7 --- /dev/null +++ b/windows/job-started-hook.ps1 @@ -0,0 +1,37 @@ +<# + Runs before EVERY job, via ACTIONS_RUNNER_HOOK_JOB_STARTED. + + The PowerShell twin of job-started-hook.sh, and it exists for exactly the + same reason. A shared setup snippet used across canvas-consuming repos + configures a git `insteadOf` rewrite with `git config --global set`, then + `--add` for a second value under the same key. On an ephemeral hosted runner + that is harmless -- the machine is destroyed when the job ends. Here the + service account outlives every job, so those values accumulate in its + .gitconfig until a later `set` collides with an already multi-valued key: + + error: cannot overwrite multiple values with a single value + + It is invisible in any one job and only appears once a machine has served + enough canvas-consuming jobs to pile up a second value. + + This is a job-STARTED hook rather than only a completed one because a + cancelled, timed-out or killed job skips the completed hook entirely, and its + accumulated .gitconfig would survive into the next job -- which is the exact + collision being prevented. Running before every job closes the gap instead of + narrowing it. + + "Clean baseline" means the file's absence: nothing in provision.ps1 writes a + .gitconfig for the service account, so that is what a freshly provisioned + machine starts with. +#> +$ErrorActionPreference = 'Continue' + +$gitconfig = Join-Path $env:USERPROFILE '.gitconfig' +if (Test-Path $gitconfig) { + Remove-Item -Force $gitconfig -ErrorAction SilentlyContinue + Write-Host "hook: reset $gitconfig to a clean baseline" +} + +# Never fail the job. This runs adjacent to work that must not be put at risk +# by a cleanup step. +exit 0 diff --git a/windows/provision.ps1 b/windows/provision.ps1 new file mode 100644 index 0000000..45c1dba --- /dev/null +++ b/windows/provision.ps1 @@ -0,0 +1,422 @@ +<# +.SYNOPSIS + Provisions a Windows self-hosted GitHub Actions runner for JKU Racing. + +.DESCRIPTION + The Windows counterpart to this repo's Dockerfile + entrypoint.sh. It is a + script rather than an image because there is no Windows equivalent of the + Linux fleet here: Windows containers cannot run on the ARM64 Parallels VM + this targets, so the runner is installed natively onto a machine that is + provisioned once and kept. + + Everything is idempotent. Re-running it upgrades the toolchain in place and + re-registers the runner against a freshly minted token; it is the intended + way to update a machine, not just to build a new one. + + Registration follows entrypoint.sh: a PAT mints a short-lived registration + token on every run, because registration tokens expire after ~1 hour and a + static one goes stale between provisioning and the next re-run. + +.PARAMETER Url + Org or repo to register against. Org-level (the default) is what the Linux + fleet uses and lets one machine serve every repo. + +.PARAMETER Pat + Classic PAT with `admin:org` (org-level) or `repo` (repo-level). Used ONLY to + mint a registration token, never stored on the machine. Prefer passing it via + the GITHUB_PAT environment variable so it stays out of your shell history. + +.PARAMETER ServiceAccount + Existing local account the runner service logs on as, e.g. ".\ci". It must + already exist -- this script will not create an account, because creating one + means choosing its password and that is yours to type, not mine to generate. + + It MUST NOT be LocalSystem, and the script refuses if you ask for it. Two + independent failures come from running the build as SYSTEM, both found the + hard way while porting hbf: + + - tauri caches its NSIS toolchain under %LOCALAPPDATA%\tauri\NSIS. Under + SYSTEM that resolves inside C:\Windows\system32\config\systemprofile, + where the download reports success but nothing lands, and the bundler + then dies with "Unable to start child process, error 0x2" -- which is + ERROR_FILE_NOT_FOUND, not the emulation failure it reads as. + - node_modules created by a SYSTEM build is owned by SYSTEM, and a later + build under any other account hangs or fails EPERM on it. + + You are never asked for the password by THIS script. config.cmd prompts for + it itself, so it goes straight into the runner's own stdin and never reaches + a command line, an environment variable, or this file. + +.PARAMETER Labels + Runner labels. Default targets the machine by what it IS, not what it builds: + an ARM64 Windows box cross-compiles x86_64-pc-windows-msvc perfectly well + (proven: `Target: x64`, payload PE machine 0x8664), so labelling it + "windows-x64" would be a lie that breaks the day someone adds an x64 box. + +.PARAMETER BuildJobs + Cap on cargo's parallelism. Defaults to half the CPUs, because this VM is + expected to share a host with other work -- on the machine this was written + for, an unthrottled VM build starves the OrbStack Linux fleet badly enough + that its runners drop with "lost communication". + +.EXAMPLE + $env:GITHUB_PAT = '' + .\provision.ps1 -ServiceAccount '.\ci' +#> +[CmdletBinding()] +param( + [string] $Url = 'https://github.com/jkuracing', + [string] $Pat = $env:GITHUB_PAT, + [Parameter(Mandatory)] + [string] $ServiceAccount, + [string] $Name = "win-$env:COMPUTERNAME", + [string] $Labels = '', + [string] $RunnerRoot = 'C:\actions-runner', + [string] $RunnerVersion = '2.331.0', + [int] $BuildJobs = 0, + [switch] $SkipToolchain +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Info ($m) { Write-Host "==> $m" -ForegroundColor Cyan } +function Warn ($m) { Write-Host "!! $m" -ForegroundColor Yellow } +function Fail ($m) { Write-Host "!! $m" -ForegroundColor Red; exit 1 } + +# -------------------------------------------------------------------------- +# Guardrails +# -------------------------------------------------------------------------- + +$id = [Security.Principal.WindowsIdentity]::GetCurrent() +if (-not ([Security.Principal.WindowsPrincipal]$id).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator)) { + Fail 'Run this from an elevated PowerShell -- installing a service and machine-level environment both need it.' +} + +# Refuse the one account that is known to produce a green provision and a +# broken runner. See the ServiceAccount help above for the two mechanisms. +if ($ServiceAccount -match '^(NT AUTHORITY\\)?(LocalSystem|SYSTEM)$') { + Fail 'ServiceAccount must not be LocalSystem: tauri''s NSIS cache and node_modules ownership both break under it. Use a dedicated local account.' +} + +if (-not $Pat) { + Fail 'No PAT. Pass -Pat or set GITHUB_PAT (classic PAT: admin:org for an org runner, repo for a repo runner).' +} + +# Fail early and clearly rather than at config.cmd time, where the error is +# "The specified account does not exist" buried in runner output. +$acctName = $ServiceAccount -replace '^\.\\', '' +if ($ServiceAccount -like '.\*' -and -not (Get-LocalUser -Name $acctName -ErrorAction SilentlyContinue)) { + Fail @" +Local account '$acctName' does not exist. Create it yourself (it needs a password you choose), then re-run: + + New-LocalUser -Name '$acctName' -Description 'GitHub Actions runner' -PasswordNeverExpires + +config.cmd grants it SeServiceLogonRight when it installs the service, so no +manual rights assignment is needed. +"@ +} + +$arch = $env:PROCESSOR_ARCHITECTURE +switch ($arch) { + 'ARM64' { $runnerArch = 'arm64'; $isArm = $true } + 'AMD64' { $runnerArch = 'x64'; $isArm = $false } + default { Fail "Unsupported architecture: $arch" } +} + +if (-not $Labels) { + $Labels = if ($isArm) { 'windows,windows-arm64' } else { 'windows,windows-x64' } +} + +if ($BuildJobs -le 0) { + $cpus = [int](Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors + $BuildJobs = [Math]::Max(1, [Math]::Floor($cpus / 2)) +} + +Info "Architecture : $arch (runner package: $runnerArch)" +Info "Runner name : $Name" +Info "Labels : $Labels" +Info "Service account: $ServiceAccount" +Info "CARGO_BUILD_JOBS: $BuildJobs" + +# -------------------------------------------------------------------------- +# Toolchain +# +# Established empirically against hbf rather than from any vendor's docs; the +# same list is documented in hbf's docs/book/src/contributing/development.md. +# +# `winget` is deliberately not used anywhere here: it hangs outright under a +# non-interactive remote session on this VM, so everything below is curl plus +# a silent installer. +# -------------------------------------------------------------------------- + +$tempDir = Join-Path $env:TEMP 'jkur-provision' +New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + +function Get-File($Uri, $OutFile) { + Info "Downloading $Uri" + # Explicit TLS 1.2 for older PowerShell hosts, and the progress bar off: + # Invoke-WebRequest's progress rendering costs more wall-clock than the + # download on a fast link. + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing +} + +function Test-Cmd($Name) { + $null -ne (Get-Command $Name -ErrorAction SilentlyContinue) +} + +function Add-MachinePath($Dir) { + $cur = [Environment]::GetEnvironmentVariable('Path', 'Machine') + if ($cur -notlike "*$Dir*") { + [Environment]::SetEnvironmentVariable('Path', "$cur;$Dir", 'Machine') + Info "PATH += $Dir" + } + if ($env:Path -notlike "*$Dir*") { $env:Path = "$env:Path;$Dir" } +} + +if (-not $SkipToolchain) { + + # Git for Windows. This is NOT optional and NOT merely a convenience: every + # composite action these workflows use declares `shell: bash`, and on a + # self-hosted Windows runner that resolves to bash.exe on PATH. Without Git + # for Windows the runner registers fine and then fails every single job. + if (-not (Test-Cmd git)) { + $exe = Join-Path $tempDir 'git-setup.exe' + $gitArch = if ($isArm) { 'arm64' } else { '64-bit' } + $rel = Invoke-RestMethod 'https://api.github.com/repos/git-for-windows/git/releases/latest' + $asset = $rel.assets | Where-Object { $_.name -like "Git-*-$gitArch.exe" } | Select-Object -First 1 + if (-not $asset) { Fail "No Git for Windows $gitArch installer in the latest release." } + Get-File $asset.browser_download_url $exe + Info 'Installing Git for Windows' + Start-Process $exe -ArgumentList '/VERYSILENT','/NORESTART','/NOCANCEL','/SP-' -Wait + } + Add-MachinePath 'C:\Program Files\Git\bin' + + # Visual Studio Build Tools: the MSVC linker. Rust's *-pc-windows-msvc + # targets cannot link without it. + $vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path $vsWhere)) { + $exe = Join-Path $tempDir 'vs_buildtools.exe' + Get-File 'https://aka.ms/vs/17/release/vs_BuildTools.exe' $exe + Info 'Installing VS Build Tools (this is the slow one)' + # ARM64 hosts need the ARM64 toolchain component explicitly; the x64 one is + # installed on both so a single machine can cross-compile either way. + $vsArgs = @( + '--quiet','--wait','--norestart','--nocache', + '--add','Microsoft.VisualStudio.Workload.VCTools', + '--add','Microsoft.VisualStudio.Component.VC.Tools.x86.x64', + '--add','Microsoft.VisualStudio.Component.Windows11SDK.22621' + ) + if ($isArm) { $vsArgs += @('--add','Microsoft.VisualStudio.Component.VC.Tools.ARM64') } + Start-Process $exe -ArgumentList $vsArgs -Wait + } + + # LLVM/clang, on ARM64 only. `ring` assembles its crypto with clang for + # aarch64-pc-windows-msvc; x64 links with MSVC alone. Installing it + # everywhere would be harmless but misleading about why it is here. + if ($isArm -and -not (Test-Path 'C:\Program Files\LLVM\bin\clang.exe')) { + $exe = Join-Path $tempDir 'llvm.exe' + $rel = Invoke-RestMethod 'https://api.github.com/repos/llvm/llvm-project/releases/latest' + $asset = $rel.assets | Where-Object { $_.name -like 'LLVM-*-woa64.exe' } | Select-Object -First 1 + if (-not $asset) { Fail 'No LLVM woa64 (ARM64) installer in the latest release.' } + Get-File $asset.browser_download_url $exe + Info 'Installing LLVM (ARM64)' + Start-Process $exe -ArgumentList '/S' -Wait + } + if ($isArm) { Add-MachinePath 'C:\Program Files\LLVM\bin' } + + # Rust. Both MSVC targets are added regardless of host so either direction of + # cross-compilation works; that is how an ARM64 box produces the shipping x64 + # installer. + if (-not (Test-Cmd rustup)) { + $exe = Join-Path $tempDir 'rustup-init.exe' + Get-File "https://static.rust-lang.org/rustup/dist/$(if($isArm){'aarch64'}else{'x86_64'})-pc-windows-msvc/rustup-init.exe" $exe + Info 'Installing Rust' + Start-Process $exe -ArgumentList '-y','--default-toolchain','stable','--profile','minimal' -Wait + } + Add-MachinePath "$env:USERPROFILE\.cargo\bin" + rustup target add x86_64-pc-windows-msvc aarch64-pc-windows-msvc + rustup component add rustfmt clippy + + # nextest. hbf's suite needs it: plain `cargo test` produces phantom 30s + # timeouts there, so a runner without it cannot run that gate at all. + if (-not (Test-Cmd cargo-nextest)) { + Info 'Installing cargo-nextest' + cargo install cargo-nextest --locked + } + + # Pkl. A build script shells out to it. There is no Windows ARM64 build, and + # none is needed -- the amd64 exe runs under emulation, and codegen is not a + # hot path. + $pklDir = 'C:\Program Files\pkl' + if (-not (Test-Path "$pklDir\pkl.exe")) { + New-Item -ItemType Directory -Force -Path $pklDir | Out-Null + $rel = Invoke-RestMethod 'https://api.github.com/repos/apple/pkl/releases/latest' + $asset = $rel.assets | Where-Object { $_.name -eq 'pkl-windows-amd64.exe' } | Select-Object -First 1 + if (-not $asset) { Fail 'No pkl-windows-amd64.exe in the latest pkl release.' } + Get-File $asset.browser_download_url "$pklDir\pkl.exe" + } + Add-MachinePath $pklDir + + # bun, for the UI bundle. hbf-gui's generate_context! embeds ui/build at + # COMPILE time, so the bundle has to exist before cargo runs. + if (-not (Test-Cmd bun)) { + Info 'Installing bun' + # bun's own installer is the supported path on Windows and picks the right + # architecture itself. + Invoke-RestMethod 'https://bun.sh/install.ps1' | Invoke-Expression + } + Add-MachinePath "$env:USERPROFILE\.bun\bin" + + # WebView2 is preinstalled on Windows 11. Checked rather than assumed, + # because a missing runtime fails at GUI launch, long after the build. + $wv = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' -ErrorAction SilentlyContinue + if (-not $wv) { Warn 'WebView2 runtime not detected. The GUI will not launch; install it if this machine runs GUI jobs.' } +} + +# -------------------------------------------------------------------------- +# Runner +# -------------------------------------------------------------------------- + +New-Item -ItemType Directory -Force -Path $RunnerRoot | Out-Null + +if (-not (Test-Path (Join-Path $RunnerRoot 'config.cmd'))) { + $pkg = Join-Path $tempDir "actions-runner-win-$runnerArch-$RunnerVersion.zip" + Get-File "https://github.com/actions/runner/releases/download/v$RunnerVersion/actions-runner-win-$runnerArch-$RunnerVersion.zip" $pkg + Info "Extracting runner to $RunnerRoot" + Expand-Archive -LiteralPath $pkg -DestinationPath $RunnerRoot -Force +} + +# The service account owns the runner tree. Without this the service starts and +# then fails on its first write to _work, which surfaces as an opaque job +# failure rather than a permissions error. +Info "Granting $ServiceAccount full control of $RunnerRoot" +# ".\name" is what config.cmd wants but is NOT a resolvable NTAccount string, +# so the ACL below needs the machine-qualified form or it throws +# IdentityNotMappedException. +$aclIdentity = if ($ServiceAccount -like '.\*') { + "$env:COMPUTERNAME\$($ServiceAccount -replace '^\.\\', '')" +} else { + $ServiceAccount +} +$acl = Get-Acl $RunnerRoot +$rule = New-Object Security.AccessControl.FileSystemAccessRule( + $aclIdentity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') +$acl.SetAccessRule($rule) +Set-Acl -Path $RunnerRoot -AclObject $acl + +# Mint a fresh registration token. Mirrors fetch_runner_token in entrypoint.sh: +# org URLs hit /orgs/{org}/..., repo URLs /repos/{owner}/{repo}/.... +$path = ($Url -replace '^https://github\.com/', '').TrimEnd('/') +$parts = $path.Split('/') +$api = if ($parts.Count -ge 2) { + "https://api.github.com/repos/$($parts[0])/$($parts[1])/actions/runners/registration-token" +} else { + "https://api.github.com/orgs/$($parts[0])/actions/runners/registration-token" +} + +Info 'Requesting a registration token' +try { + $resp = Invoke-RestMethod -Method Post -Uri $api -Headers @{ + Authorization = "Bearer $Pat" + Accept = 'application/vnd.github+json' + } +} catch { + Fail "Could not mint a registration token from $api -- check the PAT's scopes. $_" +} +$token = $resp.token + +Push-Location $RunnerRoot +try { + # Remove any previous registration so re-running this script is an upgrade + # rather than an error. `.runner_migrated` MUST be in this list: the runner + # self-updates in place and drops that marker, and config.cmd treats the + # marker ALONE as proof it is already configured. Leaving it behind is what + # silently took the Linux fleet offline about ten days after a rebuild. + if (Get-Service 'actions.runner.*' -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "*$Name*" }) { + Info 'Removing the existing service registration' + try { & .\config.cmd remove --token $token } catch { Warn "config.cmd remove failed: $_" } + } + foreach ($stale in '.runner','.credentials','.credentials_rsaparams','.runner_migrated','.credentials_migrated') { + # Named explicitly rather than via Get-ChildItem -Include, which needs a + # wildcard path to match anything and would silently clean nothing here. + Remove-Item -Force -LiteralPath $stale -ErrorAction SilentlyContinue + } + + Info 'Configuring the runner as a Windows service' + Warn 'config.cmd will now prompt for the service account password. It goes straight into the runner and is not stored, logged, or passed on a command line.' + + # NOTE: deliberately NOT --unattended. Everything else is supplied, so the + # only thing it can prompt for is the password -- which is exactly where we + # want it entered. Passing --windowslogonpassword instead would put the + # password in this process's command line, visible to any other process on + # the machine for the lifetime of the call. + & .\config.cmd ` + --url $Url ` + --token $token ` + --name $Name ` + --labels $Labels ` + --work '_work' ` + --replace ` + --runasservice ` + --windowslogonaccount $ServiceAccount + + if ($LASTEXITCODE -ne 0) { Fail "config.cmd exited $LASTEXITCODE" } +} finally { + Pop-Location +} + +# -------------------------------------------------------------------------- +# Machine environment +# +# The Linux entrypoint exports these before exec'ing run.sh. A Windows service +# has no equivalent hook, and the runner's `.env` file is read only by the +# Linux systemd unit -- so machine-level environment is the portable place. +# The service picks these up at start, which is why it is restarted below. +# -------------------------------------------------------------------------- + +$hooks = Join-Path $RunnerRoot 'hooks' +New-Item -ItemType Directory -Force -Path $hooks | Out-Null +foreach ($hook in 'job-started-hook.ps1','job-completed-hook.ps1') { + $src = Join-Path $PSScriptRoot $hook + # Copied from beside this script, so a lone provision.ps1 downloaded without + # the rest of windows/ fails here loudly rather than registering a runner + # whose hooks silently do not exist. + if (-not (Test-Path $src)) { Fail "Missing $hook next to provision.ps1 -- clone the repo rather than downloading the script alone." } + Copy-Item -Force $src $hooks +} + +$machineEnv = @{ + # Same reasoning as entrypoint.sh: without a cap, cargo sizes its thread pool + # from the host core count and swamps a machine that is sharing a host. + 'CARGO_BUILD_JOBS' = "$BuildJobs" + 'CARGO_INCREMENTAL' = '0' + 'CARGO_PROFILE_DEV_DEBUG' = 'line-tables-only' + 'ACTIONS_RUNNER_HOOK_JOB_STARTED' = (Join-Path $hooks 'job-started-hook.ps1') + 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED' = (Join-Path $hooks 'job-completed-hook.ps1') + 'SWEEP_MAX_GB' = '8' +} +foreach ($k in $machineEnv.Keys) { + [Environment]::SetEnvironmentVariable($k, $machineEnv[$k], 'Machine') + Info "env $k = $($machineEnv[$k])" +} + +$svc = Get-Service | Where-Object { $_.Name -like 'actions.runner.*' } | Select-Object -First 1 +if ($svc) { + Info "Restarting $($svc.Name) so it picks up the machine environment" + Restart-Service $svc.Name + Start-Sleep -Seconds 3 + $svc = Get-Service $svc.Name + Info "Service status: $($svc.Status)" + if ($svc.Status -ne 'Running') { + Fail "Service is $($svc.Status). Check Event Viewer -> Windows Logs -> Application, and confirm the account password was correct." + } +} else { + Fail 'No actions.runner.* service found after configuration.' +} + +Info 'Done. The runner should now appear under the org''s Actions > Runners.' From 4085697378aa4d55b0977eda39913ddf80b21650 Mon Sep 17 00:00:00 2001 From: Riccardo Persello Date: Mon, 7 Sep 2026 18:33:32 +0200 Subject: [PATCH 02/13] test(windows): verify the parts that can be verified without provisioning Run against the ARM64 VM now it is back up. All three scripts parse under Windows PowerShell 5.1 Desktop, which is not a formality: the machine has no PowerShell 7, so the runner will invoke the hooks with 5.1 and the deliberate avoidance of `??` in job-completed-hook.ps1 is load-bearing rather than defensive. All four guardrails were exercised and refuse correctly: non-elevated, LocalSystem as the service account, a nonexistent local account, and a missing PAT. Each exits before touching the machine. Two changes came out of testing rather than review: -RunnerVersion no longer defaults to a hand-pinned 2.331.0. Latest is already 2.337.0, and since the runner self-updates on first contact with GitHub a pin buys nothing while guaranteeing the first job runs on a just-replaced binary. It now resolves the latest release the same way the Git, LLVM and pkl installs already do, and stays overridable to reproduce a specific machine. Confirmed actions/runner does publish a win-arm64 asset, so an ARM64 runner is supported at all -- worth checking before depending on it. -DryRun prints every derived value and exits without side effects. Added to make the derivation testable, but it earns its place for anyone about to provision a machine they care about. Verified it resolves org URLs to /orgs/{org}/... and repo URLs to /repos/{owner}/{repo}/..., detects ARM64, defaults labels to machine identity, and halves the CPU count for CARGO_BUILD_JOBS. Still unverified: the toolchain installs, the VS Build Tools component pin, and whether config.cmd prompts for only the password when given --runasservice --windowslogonaccount without --unattended. Those need a real service account, whose password is the user's to type. Co-Authored-By: Claude Opus 5 --- windows/provision.ps1 | 49 ++++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/windows/provision.ps1 b/windows/provision.ps1 index 45c1dba..b559b49 100644 --- a/windows/provision.ps1 +++ b/windows/provision.ps1 @@ -72,9 +72,18 @@ param( [string] $Name = "win-$env:COMPUTERNAME", [string] $Labels = '', [string] $RunnerRoot = 'C:\actions-runner', - [string] $RunnerVersion = '2.331.0', + # Empty means "resolve the latest release at run time", which is what every + # other download here does. A hand-pinned version only goes stale: the runner + # self-updates on first contact with GitHub anyway, so pinning buys nothing + # and guarantees the first job runs on a just-replaced binary. Set it + # explicitly only to reproduce a specific machine. + [string] $RunnerVersion = '', [int] $BuildJobs = 0, - [switch] $SkipToolchain + [switch] $SkipToolchain, + # Print everything this run would derive, then exit without touching the + # machine. Worth having before provisioning a box you care about, and it is + # how the derivation below is tested without side effects. + [switch] $DryRun ) $ErrorActionPreference = 'Stop' @@ -118,6 +127,14 @@ manual rights assignment is needed. "@ } +if (-not $RunnerVersion) { + try { + $RunnerVersion = (Invoke-RestMethod 'https://api.github.com/repos/actions/runner/releases/latest').tag_name -replace '^v', '' + } catch { + Fail "Could not resolve the latest actions/runner release: $_. Pass -RunnerVersion to pin one." + } +} + $arch = $env:PROCESSOR_ARCHITECTURE switch ($arch) { 'ARM64' { $runnerArch = 'arm64'; $isArm = $true } @@ -134,11 +151,29 @@ if ($BuildJobs -le 0) { $BuildJobs = [Math]::Max(1, [Math]::Floor($cpus / 2)) } +# Derived here rather than at point of use so -DryRun can show it. Mirrors +# fetch_runner_token in entrypoint.sh: org URLs hit /orgs/{org}/..., repo URLs +# /repos/{owner}/{repo}/.... Pure string work -- no network call happens here. +$path = ($Url -replace '^https://github\.com/', '').TrimEnd('/') +$parts = $path.Split('/') +$api = if ($parts.Count -ge 2) { + "https://api.github.com/repos/$($parts[0])/$($parts[1])/actions/runners/registration-token" +} else { + "https://api.github.com/orgs/$($parts[0])/actions/runners/registration-token" +} + Info "Architecture : $arch (runner package: $runnerArch)" Info "Runner name : $Name" Info "Labels : $Labels" Info "Service account: $ServiceAccount" Info "CARGO_BUILD_JOBS: $BuildJobs" +Info "Registration API: $api" +Info "Runner root : $RunnerRoot (actions-runner $RunnerVersion)" + +if ($DryRun) { + Warn 'DryRun: nothing installed, nothing registered, no machine state changed.' + exit 0 +} # -------------------------------------------------------------------------- # Toolchain @@ -308,16 +343,6 @@ $rule = New-Object Security.AccessControl.FileSystemAccessRule( $acl.SetAccessRule($rule) Set-Acl -Path $RunnerRoot -AclObject $acl -# Mint a fresh registration token. Mirrors fetch_runner_token in entrypoint.sh: -# org URLs hit /orgs/{org}/..., repo URLs /repos/{owner}/{repo}/.... -$path = ($Url -replace '^https://github\.com/', '').TrimEnd('/') -$parts = $path.Split('/') -$api = if ($parts.Count -ge 2) { - "https://api.github.com/repos/$($parts[0])/$($parts[1])/actions/runners/registration-token" -} else { - "https://api.github.com/orgs/$($parts[0])/actions/runners/registration-token" -} - Info 'Requesting a registration token' try { $resp = Invoke-RestMethod -Method Post -Uri $api -Headers @{ From 723fb3ab93cbd95b2993a8473004ac99b3a19550 Mon Sep 17 00:00:00 2001 From: Riccardo Persello Date: Mon, 7 Sep 2026 18:40:01 +0200 Subject: [PATCH 03/13] feat(windows): one self-contained script, x64 and ARM64 alike Copy provision.ps1 to a machine and run it -- that is the whole procedure. The job hooks are embedded as here-strings and written to \hooks during provisioning, so the script needs nothing else from this repo. -DryRun emits them to %TEMP% instead, which makes them inspectable before committing to a machine and is how the emission is tested. bun now comes from its published per-architecture zip rather than by piping bun.sh/install.ps1 into Invoke-Expression. It publishes bun-windows-aarch64.zip alongside bun-windows-x64.zip, so the direct download works identically on both architectures, is deterministic, and does not execute a remote script as Administrator. Note an x64 machine older than bun's baseline cutoff needs the -baseline zip and will otherwise die on an illegal instruction. Verified on the ARM64 VM: provision.ps1 parses under PowerShell 5.1, -DryRun derives correctly, and the hooks it emits parse under 5.1 as well. Co-Authored-By: Claude Opus 5 --- README.md | 32 +++++- windows/job-completed-hook.ps1 | 73 ------------- windows/job-started-hook.ps1 | 37 ------- windows/provision.ps1 | 184 ++++++++++++++++++++++++++++++--- 4 files changed, 197 insertions(+), 129 deletions(-) delete mode 100644 windows/job-completed-hook.ps1 delete mode 100644 windows/job-started-hook.ps1 diff --git a/README.md b/README.md index e8f6661..fcef039 100644 --- a/README.md +++ b/README.md @@ -143,10 +143,27 @@ provisioning, and it is idempotent -- re-running it upgrades the toolchain and re-registers against a freshly minted token, which is the intended way to update a machine rather than only to build one. +`provision.ps1` is **one self-contained file**. It needs nothing else from +this repo -- the job hooks are embedded and written out during provisioning -- +so it can be copied to a new machine on its own, and it handles x64 and ARM64 +identically. + ```powershell # Elevated PowerShell, on the machine that will run jobs. $env:GITHUB_PAT = '' -.\windows\provision.ps1 -ServiceAccount '.\ci' + +# Preview everything it would derive, without touching the machine: +.\provision.ps1 -ServiceAccount '.\ci' -DryRun + +# Then for real: +.\provision.ps1 -ServiceAccount '.\ci' +``` + +Or fetch just that file onto a fresh machine: + +```powershell +$u = 'https://raw.githubusercontent.com/jkuracing/github-runner/main/windows/provision.ps1' +Invoke-WebRequest $u -OutFile provision.ps1 -UseBasicParsing ``` ### The service account is not optional, and must not be SYSTEM @@ -203,9 +220,16 @@ session on this VM, so every install is `curl` plus a silent installer. ### Hooks and machine environment -`windows/job-started-hook.ps1` and `windows/job-completed-hook.ps1` are the -PowerShell twins of the `.sh` hooks, for the same reasons: resetting an -accumulating `.gitconfig` before each job, and bounding `target/` after it. +The job hooks are embedded in `provision.ps1` and written to +`\hooks\` during provisioning -- that is what keeps the script a +single file. They are PowerShell twins of the `.sh` hooks, for the same +reasons: resetting an accumulating `.gitconfig` before each job, and bounding +`target/` after it. `-DryRun` writes them to `%TEMP%` so you can read exactly +what will be installed. + +They are written for **Windows PowerShell 5.1** deliberately. The target VM has +no PowerShell 7, so that is what the runner invokes hooks with; a `??` in the +sweep hook would have failed to parse on every job. The Linux entrypoint exports the cargo knobs before `run.sh`; a Windows service has no equivalent hook and the runner's `.env` is read only by the Linux systemd unit, so `provision.ps1` sets them as **machine-level** environment and diff --git a/windows/job-completed-hook.ps1 b/windows/job-completed-hook.ps1 deleted file mode 100644 index 13344a6..0000000 --- a/windows/job-completed-hook.ps1 +++ /dev/null @@ -1,73 +0,0 @@ -<# - Runs after EVERY job, via ACTIONS_RUNNER_HOOK_JOB_COMPLETED. - - The PowerShell twin of job-completed-hook.sh: bound `target/` between jobs so - a persistent machine does not drift until the disk fills. On the Linux fleet - that surfaced not as "out of disk" but as a linker bus error, which cost real - time to diagnose; a Windows machine will fail differently but no more - clearly. - - The budget is higher here than the fleet's 4 GB because this is ONE machine - rather than twelve replicas sharing a volume, and because a Windows build - tree carries both the host and the cross target -- hbf builds - aarch64-pc-windows-msvc and x86_64-pc-windows-msvc from one checkout. - - Why a job hook rather than a scheduled task: the runner invokes this between - jobs, so it can never delete a target/ out from under a live compile. - - This bounds STEADY STATE, not the peak. A build in flight can exceed the - threshold and is only swept once it finishes. -#> -$ErrorActionPreference = 'Continue' - -# Written the long way rather than with `??`: the runner may invoke this hook -# with Windows PowerShell 5.1, which has no null-coalescing operator and would -# fail to parse the file outright. -$maxGb = if ($env:SWEEP_MAX_GB) { [int]$env:SWEEP_MAX_GB } else { 8 } -# The runner root's _work, NOT RUNNER_WORKSPACE. RUNNER_WORKSPACE is -# per-repository, so using it would enforce the budget once per repo rather -# than once per machine -- on the Linux fleet that let each replica hold twice -# its nominal budget with two repos checked out. -$workDir = if ($env:SWEEP_WORK_DIR) { $env:SWEEP_WORK_DIR } else { 'C:\actions-runner\_work' } -if (-not (Test-Path $workDir)) { exit 0 } - -function Get-SizeMb($Path) { - try { - [Math]::Round((Get-ChildItem -LiteralPath $Path -Recurse -Force -File -ErrorAction SilentlyContinue | - Measure-Object -Property Length -Sum).Sum / 1MB) - } catch { 0 } -} - -$usedMb = Get-SizeMb $workDir -$limitMb = $maxGb * 1024 -if ($usedMb -le $limitMb) { - Write-Host "sweep: _work at $usedMb MB, under the $limitMb MB budget -- keeping it warm" - exit 0 -} - -Write-Host "sweep: _work at $usedMb MB exceeds $limitMb MB -- removing target dirs" - -# Largest first, stopping as soon as the budget is met, so the machine keeps as -# much warmth as the budget allows instead of being emptied wholesale. Only -# genuine cargo target dirs are touched: the CACHEDIR.TAG / debug / release -# test avoids deleting a source directory that merely happens to be named -# "target". -$targets = Get-ChildItem -LiteralPath $workDir -Recurse -Directory -Force -Filter 'target' -ErrorAction SilentlyContinue | - Where-Object { - (Test-Path (Join-Path $_.FullName 'CACHEDIR.TAG')) -or - (Test-Path (Join-Path $_.FullName 'debug')) -or - (Test-Path (Join-Path $_.FullName 'release')) - } | - ForEach-Object { [pscustomobject]@{ Path = $_.FullName; Mb = Get-SizeMb $_.FullName } } | - Sort-Object Mb -Descending - -foreach ($t in $targets) { - if ($usedMb -le $limitMb) { break } - Remove-Item -LiteralPath $t.Path -Recurse -Force -ErrorAction SilentlyContinue - $usedMb -= $t.Mb - Write-Host "sweep: removed $($t.Path) ($($t.Mb) MB), now ~$usedMb MB" -} - -# Never fail the job: this runs after the work that matters is already done and -# reported, and a sweep problem must not turn a green job red. -exit 0 diff --git a/windows/job-started-hook.ps1 b/windows/job-started-hook.ps1 deleted file mode 100644 index a841bf7..0000000 --- a/windows/job-started-hook.ps1 +++ /dev/null @@ -1,37 +0,0 @@ -<# - Runs before EVERY job, via ACTIONS_RUNNER_HOOK_JOB_STARTED. - - The PowerShell twin of job-started-hook.sh, and it exists for exactly the - same reason. A shared setup snippet used across canvas-consuming repos - configures a git `insteadOf` rewrite with `git config --global set`, then - `--add` for a second value under the same key. On an ephemeral hosted runner - that is harmless -- the machine is destroyed when the job ends. Here the - service account outlives every job, so those values accumulate in its - .gitconfig until a later `set` collides with an already multi-valued key: - - error: cannot overwrite multiple values with a single value - - It is invisible in any one job and only appears once a machine has served - enough canvas-consuming jobs to pile up a second value. - - This is a job-STARTED hook rather than only a completed one because a - cancelled, timed-out or killed job skips the completed hook entirely, and its - accumulated .gitconfig would survive into the next job -- which is the exact - collision being prevented. Running before every job closes the gap instead of - narrowing it. - - "Clean baseline" means the file's absence: nothing in provision.ps1 writes a - .gitconfig for the service account, so that is what a freshly provisioned - machine starts with. -#> -$ErrorActionPreference = 'Continue' - -$gitconfig = Join-Path $env:USERPROFILE '.gitconfig' -if (Test-Path $gitconfig) { - Remove-Item -Force $gitconfig -ErrorAction SilentlyContinue - Write-Host "hook: reset $gitconfig to a clean baseline" -} - -# Never fail the job. This runs adjacent to work that must not be put at risk -# by a cleanup step. -exit 0 diff --git a/windows/provision.ps1 b/windows/provision.ps1 index b559b49..aa65f97 100644 --- a/windows/provision.ps1 +++ b/windows/provision.ps1 @@ -93,6 +93,144 @@ function Info ($m) { Write-Host "==> $m" -ForegroundColor Cyan } function Warn ($m) { Write-Host "!! $m" -ForegroundColor Yellow } function Fail ($m) { Write-Host "!! $m" -ForegroundColor Red; exit 1 } +# -------------------------------------------------------------------------- +# Job hooks +# +# Embedded rather than shipped as sibling files so this script is the ONLY +# thing you need on a new machine: download it, run it, done. The trade is that +# the hook sources live inside a here-string; they are single-quoted, so +# nothing in them is expanded by this script. +# -------------------------------------------------------------------------- + +$JobStartedHook = @' +<# + Runs before EVERY job, via ACTIONS_RUNNER_HOOK_JOB_STARTED. + + The PowerShell twin of job-started-hook.sh, and it exists for exactly the + same reason. A shared setup snippet used across canvas-consuming repos + configures a git `insteadOf` rewrite with `git config --global set`, then + `--add` for a second value under the same key. On an ephemeral hosted runner + that is harmless -- the machine is destroyed when the job ends. Here the + service account outlives every job, so those values accumulate in its + .gitconfig until a later `set` collides with an already multi-valued key: + + error: cannot overwrite multiple values with a single value + + It is invisible in any one job and only appears once a machine has served + enough canvas-consuming jobs to pile up a second value. + + This is a job-STARTED hook rather than only a completed one because a + cancelled, timed-out or killed job skips the completed hook entirely, and its + accumulated .gitconfig would survive into the next job -- which is the exact + collision being prevented. Running before every job closes the gap instead of + narrowing it. + + "Clean baseline" means the file's absence: nothing in provision.ps1 writes a + .gitconfig for the service account, so that is what a freshly provisioned + machine starts with. +#> +$ErrorActionPreference = 'Continue' + +$gitconfig = Join-Path $env:USERPROFILE '.gitconfig' +if (Test-Path $gitconfig) { + Remove-Item -Force $gitconfig -ErrorAction SilentlyContinue + Write-Host "hook: reset $gitconfig to a clean baseline" +} + +# Never fail the job. This runs adjacent to work that must not be put at risk +# by a cleanup step. +exit 0 +'@ + +$JobCompletedHook = @' +<# + Runs after EVERY job, via ACTIONS_RUNNER_HOOK_JOB_COMPLETED. + + The PowerShell twin of job-completed-hook.sh: bound `target/` between jobs so + a persistent machine does not drift until the disk fills. On the Linux fleet + that surfaced not as "out of disk" but as a linker bus error, which cost real + time to diagnose; a Windows machine will fail differently but no more + clearly. + + The budget is higher here than the fleet's 4 GB because this is ONE machine + rather than twelve replicas sharing a volume, and because a Windows build + tree carries both the host and the cross target -- hbf builds + aarch64-pc-windows-msvc and x86_64-pc-windows-msvc from one checkout. + + Why a job hook rather than a scheduled task: the runner invokes this between + jobs, so it can never delete a target/ out from under a live compile. + + This bounds STEADY STATE, not the peak. A build in flight can exceed the + threshold and is only swept once it finishes. +#> +$ErrorActionPreference = 'Continue' + +# Written the long way rather than with `??`: the runner may invoke this hook +# with Windows PowerShell 5.1, which has no null-coalescing operator and would +# fail to parse the file outright. +$maxGb = if ($env:SWEEP_MAX_GB) { [int]$env:SWEEP_MAX_GB } else { 8 } +# The runner root's _work, NOT RUNNER_WORKSPACE. RUNNER_WORKSPACE is +# per-repository, so using it would enforce the budget once per repo rather +# than once per machine -- on the Linux fleet that let each replica hold twice +# its nominal budget with two repos checked out. +$workDir = if ($env:SWEEP_WORK_DIR) { $env:SWEEP_WORK_DIR } else { 'C:\actions-runner\_work' } +if (-not (Test-Path $workDir)) { exit 0 } + +function Get-SizeMb($Path) { + try { + [Math]::Round((Get-ChildItem -LiteralPath $Path -Recurse -Force -File -ErrorAction SilentlyContinue | + Measure-Object -Property Length -Sum).Sum / 1MB) + } catch { 0 } +} + +$usedMb = Get-SizeMb $workDir +$limitMb = $maxGb * 1024 +if ($usedMb -le $limitMb) { + Write-Host "sweep: _work at $usedMb MB, under the $limitMb MB budget -- keeping it warm" + exit 0 +} + +Write-Host "sweep: _work at $usedMb MB exceeds $limitMb MB -- removing target dirs" + +# Largest first, stopping as soon as the budget is met, so the machine keeps as +# much warmth as the budget allows instead of being emptied wholesale. Only +# genuine cargo target dirs are touched: the CACHEDIR.TAG / debug / release +# test avoids deleting a source directory that merely happens to be named +# "target". +$targets = Get-ChildItem -LiteralPath $workDir -Recurse -Directory -Force -Filter 'target' -ErrorAction SilentlyContinue | + Where-Object { + (Test-Path (Join-Path $_.FullName 'CACHEDIR.TAG')) -or + (Test-Path (Join-Path $_.FullName 'debug')) -or + (Test-Path (Join-Path $_.FullName 'release')) + } | + ForEach-Object { [pscustomobject]@{ Path = $_.FullName; Mb = Get-SizeMb $_.FullName } } | + Sort-Object Mb -Descending + +foreach ($t in $targets) { + if ($usedMb -le $limitMb) { break } + Remove-Item -LiteralPath $t.Path -Recurse -Force -ErrorAction SilentlyContinue + $usedMb -= $t.Mb + Write-Host "sweep: removed $($t.Path) ($($t.Mb) MB), now ~$usedMb MB" +} + +# Never fail the job: this runs after the work that matters is already done and +# reported, and a sweep problem must not turn a green job red. +exit 0 +'@ + +function Write-HookFiles { + param([Parameter(Mandatory)][string] $Destination) + New-Item -ItemType Directory -Force -Path $Destination | Out-Null + $a = Join-Path $Destination 'job-started-hook.ps1' + $b = Join-Path $Destination 'job-completed-hook.ps1' + # ASCII, not the default UTF-8-with-BOM of Set-Content on 5.1: a BOM ahead of + # the first line is tolerated by PowerShell but shows up in diffs and logs. + $JobStartedHook | Set-Content -LiteralPath $a -Encoding ASCII + $JobCompletedHook | Set-Content -LiteralPath $b -Encoding ASCII + Info "Hooks written to $Destination" + return @($a, $b) +} + # -------------------------------------------------------------------------- # Guardrails # -------------------------------------------------------------------------- @@ -171,7 +309,11 @@ Info "Registration API: $api" Info "Runner root : $RunnerRoot (actions-runner $RunnerVersion)" if ($DryRun) { - Warn 'DryRun: nothing installed, nothing registered, no machine state changed.' + # The hooks go to TEMP rather than the runner root: it makes them + # inspectable (and testable) without writing anything the machine keeps. + $preview = Join-Path $env:TEMP 'jkur-provision-hooks' + Write-HookFiles -Destination $preview | Out-Null + Warn "DryRun: nothing installed, nothing registered, no service touched. Hooks emitted to $preview for inspection." exit 0 } @@ -298,13 +440,32 @@ if (-not $SkipToolchain) { # bun, for the UI bundle. hbf-gui's generate_context! embeds ui/build at # COMPILE time, so the bundle has to exist before cargo runs. - if (-not (Test-Cmd bun)) { - Info 'Installing bun' - # bun's own installer is the supported path on Windows and picks the right - # architecture itself. - Invoke-RestMethod 'https://bun.sh/install.ps1' | Invoke-Expression + # bun publishes a per-architecture zip for Windows, including aarch64, so it + # is fetched directly rather than by piping bun.sh/install.ps1 into + # Invoke-Expression: the zip is deterministic, works the same on both + # architectures, and does not execute a remote script as Administrator. + $bunDir = 'C:\Program Files\bun' + if (-not (Test-Path "$bunDir\bun.exe")) { + $bunArch = if ($isArm) { 'aarch64' } else { 'x64' } + $rel = Invoke-RestMethod 'https://api.github.com/repos/oven-sh/bun/releases/latest' + $asset = $rel.assets | Where-Object { $_.name -eq "bun-windows-$bunArch.zip" } | Select-Object -First 1 + if (-not $asset) { Fail "No bun-windows-$bunArch.zip in the latest bun release." } + $zip = Join-Path $tempDir "bun-$bunArch.zip" + Get-File $asset.browser_download_url $zip + $staging = Join-Path $tempDir 'bun-extract' + Remove-Item -Recurse -Force $staging -ErrorAction SilentlyContinue + Expand-Archive -LiteralPath $zip -DestinationPath $staging -Force + New-Item -ItemType Directory -Force -Path $bunDir | Out-Null + # The zip nests everything under bun-windows-/. + Get-ChildItem -Recurse -File -Path $staging -Filter 'bun.exe' | + Select-Object -First 1 | + ForEach-Object { Copy-Item -Force $_.FullName "$bunDir\bun.exe" } + if (-not (Test-Path "$bunDir\bun.exe")) { Fail 'bun.exe not found inside the downloaded zip.' } + # An x64 machine older than the baseline cutoff needs + # bun-windows-x64-baseline.zip instead; bun will fail with an illegal + # instruction rather than a clear message if so. } - Add-MachinePath "$env:USERPROFILE\.bun\bin" + Add-MachinePath $bunDir # WebView2 is preinstalled on Windows 11. Checked rather than assumed, # because a missing runtime fails at GUI launch, long after the build. @@ -406,14 +567,7 @@ try { $hooks = Join-Path $RunnerRoot 'hooks' New-Item -ItemType Directory -Force -Path $hooks | Out-Null -foreach ($hook in 'job-started-hook.ps1','job-completed-hook.ps1') { - $src = Join-Path $PSScriptRoot $hook - # Copied from beside this script, so a lone provision.ps1 downloaded without - # the rest of windows/ fails here loudly rather than registering a runner - # whose hooks silently do not exist. - if (-not (Test-Path $src)) { Fail "Missing $hook next to provision.ps1 -- clone the repo rather than downloading the script alone." } - Copy-Item -Force $src $hooks -} +Write-HookFiles -Destination $hooks $machineEnv = @{ # Same reasoning as entrypoint.sh: without a cap, cargo sizes its thread pool From e6a5979bd1403ec9f720c03a1b6d80935634a4e3 Mon Sep 17 00:00:00 2001 From: Riccardo Persello Date: Mon, 7 Sep 2026 18:52:33 +0200 Subject: [PATCH 04/13] fix(windows): install Rust machine-wide, not into the provisioner's profile Rust was the one toolchain here defaulting to a per-user path, and the failure mode is invisible until the first job. Provisioning runs elevated -- often as SYSTEM -- while jobs run as the service account, so rustup's default %USERPROFILE% install puts cargo in the provisioning account's profile, publishes that unreadable path on the machine PATH, and the runner then registers cleanly and fails every job with "cargo not found". CARGO_HOME and RUSTUP_HOME are now set to C:\rust before rustup-init runs, and the service account is granted Modify there because cargo writes to CARGO_HOME (the registry cache, and `cargo install`) rather than only reading it. Found by reading the script against the account layout it would actually meet, not by running it. Two options come with it, both of which the Linux side already has in spirit: -RegistrationToken mirrors entrypoint.sh's RUNNER_TOKEN. Minting the token where the PAT already lives and passing only the short-lived result keeps the PAT off the provisioned machine entirely. -SkipRegistration stops after the toolchain. config.cmd prompts for the account password on an interactive console, so a remote or scripted session cannot answer it; this splits the long unattended half from the short interactive one and prints the exact command to finish with. Co-Authored-By: Claude Opus 5 --- windows/provision.ps1 | 181 +++++++++++++++++++++++++++--------------- 1 file changed, 119 insertions(+), 62 deletions(-) diff --git a/windows/provision.ps1 b/windows/provision.ps1 index aa65f97..62889bf 100644 --- a/windows/provision.ps1 +++ b/windows/provision.ps1 @@ -67,6 +67,11 @@ param( [string] $Url = 'https://github.com/jkuracing', [string] $Pat = $env:GITHUB_PAT, + # A registration token minted elsewhere, as an alternative to -Pat. The Linux + # entrypoint accepts RUNNER_TOKEN for the same reason: it lets the PAT stay + # off this machine entirely -- mint the token where the PAT already lives and + # pass only the short-lived result. Expires in ~1 hour. + [string] $RegistrationToken = $env:RUNNER_TOKEN, [Parameter(Mandatory)] [string] $ServiceAccount, [string] $Name = "win-$env:COMPUTERNAME", @@ -80,6 +85,12 @@ param( [string] $RunnerVersion = '', [int] $BuildJobs = 0, [switch] $SkipToolchain, + # Skip the registration step and stop after the toolchain. config.cmd prompts + # for the service account password on an interactive console, so a session + # without a real stdin (a remote `prlctl exec`, a scripted deploy) cannot + # answer it. This lets the long unattended half run there and the short + # interactive half be done by a person. + [switch] $SkipRegistration, # Print everything this run would derive, then exit without touching the # machine. Worth having before provisioning a box you care about, and it is # how the derivation below is tested without side effects. @@ -247,8 +258,8 @@ if ($ServiceAccount -match '^(NT AUTHORITY\\)?(LocalSystem|SYSTEM)$') { Fail 'ServiceAccount must not be LocalSystem: tauri''s NSIS cache and node_modules ownership both break under it. Use a dedicated local account.' } -if (-not $Pat) { - Fail 'No PAT. Pass -Pat or set GITHUB_PAT (classic PAT: admin:org for an org runner, repo for a repo runner).' +if (-not $Pat -and -not $RegistrationToken -and -not $SkipRegistration) { + Fail 'No credential. Pass -Pat/GITHUB_PAT (classic PAT: admin:org for an org runner, repo for a repo runner), or -RegistrationToken/RUNNER_TOKEN if you minted one elsewhere, or -SkipRegistration to install only the toolchain.' } # Fail early and clearly rather than at config.cmd time, where the error is @@ -273,6 +284,15 @@ if (-not $RunnerVersion) { } } +# ".\name" is what config.cmd wants but is NOT a resolvable NTAccount string, +# so every ACL below needs the machine-qualified form or it throws +# IdentityNotMappedException. +$aclIdentity = if ($ServiceAccount -like '.\*') { + "$env:COMPUTERNAME\$($ServiceAccount -replace '^\.\\', '')" +} else { + $ServiceAccount +} + $arch = $env:PROCESSOR_ARCHITECTURE switch ($arch) { 'ARM64' { $runnerArch = 'arm64'; $isArm = $true } @@ -408,13 +428,38 @@ if (-not $SkipToolchain) { # Rust. Both MSVC targets are added regardless of host so either direction of # cross-compilation works; that is how an ARM64 box produces the shipping x64 # installer. - if (-not (Test-Cmd rustup)) { + # Rust goes to a MACHINE-WIDE location, not %USERPROFILE%. + # + # This is the one toolchain here that defaults to a per-user path, and + # getting it wrong is invisible until the first job. Provisioning runs + # elevated -- often as SYSTEM -- while jobs run as the service account, so a + # default install puts cargo in the provisioning account's profile, publishes + # that unreadable path on the machine PATH, and the runner then registers + # cleanly and fails every job with "cargo not found". Setting CARGO_HOME and + # RUSTUP_HOME before rustup-init runs makes the install account-independent. + $rustRoot = 'C:\rust' + $cargoHome = Join-Path $rustRoot 'cargo' + $rustupHome = Join-Path $rustRoot 'rustup' + [Environment]::SetEnvironmentVariable('CARGO_HOME', $cargoHome, 'Machine') + [Environment]::SetEnvironmentVariable('RUSTUP_HOME', $rustupHome, 'Machine') + $env:CARGO_HOME = $cargoHome + $env:RUSTUP_HOME = $rustupHome + + if (-not (Test-Path (Join-Path $cargoHome 'bin\rustup.exe'))) { $exe = Join-Path $tempDir 'rustup-init.exe' Get-File "https://static.rust-lang.org/rustup/dist/$(if($isArm){'aarch64'}else{'x86_64'})-pc-windows-msvc/rustup-init.exe" $exe - Info 'Installing Rust' - Start-Process $exe -ArgumentList '-y','--default-toolchain','stable','--profile','minimal' -Wait + Info "Installing Rust into $rustRoot" + Start-Process $exe -ArgumentList '-y','--default-toolchain','stable','--profile','minimal' -Wait -NoNewWindow } - Add-MachinePath "$env:USERPROFILE\.cargo\bin" + Add-MachinePath (Join-Path $cargoHome 'bin') + + # cargo writes to CARGO_HOME (the registry cache, and `cargo install`), so + # the service account needs Modify here, not merely Read. + $rustAcl = Get-Acl $rustRoot + $rustAcl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + $aclIdentity, 'Modify', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + Set-Acl -Path $rustRoot -AclObject $rustAcl + Info "Granted $aclIdentity Modify on $rustRoot" rustup target add x86_64-pc-windows-msvc aarch64-pc-windows-msvc rustup component add rustfmt clippy @@ -490,70 +535,71 @@ if (-not (Test-Path (Join-Path $RunnerRoot 'config.cmd'))) { # then fails on its first write to _work, which surfaces as an opaque job # failure rather than a permissions error. Info "Granting $ServiceAccount full control of $RunnerRoot" -# ".\name" is what config.cmd wants but is NOT a resolvable NTAccount string, -# so the ACL below needs the machine-qualified form or it throws -# IdentityNotMappedException. -$aclIdentity = if ($ServiceAccount -like '.\*') { - "$env:COMPUTERNAME\$($ServiceAccount -replace '^\.\\', '')" -} else { - $ServiceAccount -} $acl = Get-Acl $RunnerRoot $rule = New-Object Security.AccessControl.FileSystemAccessRule( $aclIdentity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') $acl.SetAccessRule($rule) Set-Acl -Path $RunnerRoot -AclObject $acl -Info 'Requesting a registration token' -try { - $resp = Invoke-RestMethod -Method Post -Uri $api -Headers @{ - Authorization = "Bearer $Pat" - Accept = 'application/vnd.github+json' - } -} catch { - Fail "Could not mint a registration token from $api -- check the PAT's scopes. $_" -} -$token = $resp.token - -Push-Location $RunnerRoot -try { - # Remove any previous registration so re-running this script is an upgrade - # rather than an error. `.runner_migrated` MUST be in this list: the runner - # self-updates in place and drops that marker, and config.cmd treats the - # marker ALONE as proof it is already configured. Leaving it behind is what - # silently took the Linux fleet offline about ten days after a rebuild. - if (Get-Service 'actions.runner.*' -ErrorAction SilentlyContinue | - Where-Object { $_.Name -like "*$Name*" }) { - Info 'Removing the existing service registration' - try { & .\config.cmd remove --token $token } catch { Warn "config.cmd remove failed: $_" } - } - foreach ($stale in '.runner','.credentials','.credentials_rsaparams','.runner_migrated','.credentials_migrated') { - # Named explicitly rather than via Get-ChildItem -Include, which needs a - # wildcard path to match anything and would silently clean nothing here. - Remove-Item -Force -LiteralPath $stale -ErrorAction SilentlyContinue +if ($SkipRegistration) { + Warn 'SkipRegistration: installing the toolchain only, leaving the runner unregistered.' +} else { + if ($RegistrationToken) { + Info 'Using the registration token supplied on the command line' + $token = $RegistrationToken + } else { + Info 'Requesting a registration token' + try { + $resp = Invoke-RestMethod -Method Post -Uri $api -Headers @{ + Authorization = "Bearer $Pat" + Accept = 'application/vnd.github+json' + } + } catch { + Fail "Could not mint a registration token from $api -- check the PAT's scopes. $_" + } + $token = $resp.token } - Info 'Configuring the runner as a Windows service' - Warn 'config.cmd will now prompt for the service account password. It goes straight into the runner and is not stored, logged, or passed on a command line.' - - # NOTE: deliberately NOT --unattended. Everything else is supplied, so the - # only thing it can prompt for is the password -- which is exactly where we - # want it entered. Passing --windowslogonpassword instead would put the - # password in this process's command line, visible to any other process on - # the machine for the lifetime of the call. - & .\config.cmd ` - --url $Url ` - --token $token ` - --name $Name ` - --labels $Labels ` - --work '_work' ` - --replace ` - --runasservice ` - --windowslogonaccount $ServiceAccount - - if ($LASTEXITCODE -ne 0) { Fail "config.cmd exited $LASTEXITCODE" } -} finally { - Pop-Location + Push-Location $RunnerRoot + try { + # Remove any previous registration so re-running this script is an upgrade + # rather than an error. `.runner_migrated` MUST be in this list: the runner + # self-updates in place and drops that marker, and config.cmd treats the + # marker ALONE as proof it is already configured. Leaving it behind is what + # silently took the Linux fleet offline about ten days after a rebuild. + if (Get-Service 'actions.runner.*' -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "*$Name*" }) { + Info 'Removing the existing service registration' + try { & .\config.cmd remove --token $token } catch { Warn "config.cmd remove failed: $_" } + } + foreach ($stale in '.runner','.credentials','.credentials_rsaparams','.runner_migrated','.credentials_migrated') { + # Named explicitly rather than via Get-ChildItem -Include, which needs a + # wildcard path to match anything and would silently clean nothing here. + Remove-Item -Force -LiteralPath $stale -ErrorAction SilentlyContinue + } + + Info 'Configuring the runner as a Windows service' + Warn 'config.cmd will now prompt for the service account password. It goes straight into the runner and is not stored, logged, or passed on a command line.' + + # NOTE: deliberately NOT --unattended. Everything else is supplied, so the + # only thing it can prompt for is the password -- which is exactly where we + # want it entered. Passing --windowslogonpassword instead would put the + # password in this process's command line, visible to any other process on + # the machine for the lifetime of the call. + & .\config.cmd ` + --url $Url ` + --token $token ` + --name $Name ` + --labels $Labels ` + --work '_work' ` + --replace ` + --runasservice ` + --windowslogonaccount $ServiceAccount + + if ($LASTEXITCODE -ne 0) { Fail "config.cmd exited $LASTEXITCODE" } + } finally { + Pop-Location + } } # -------------------------------------------------------------------------- @@ -585,6 +631,17 @@ foreach ($k in $machineEnv.Keys) { } $svc = Get-Service | Where-Object { $_.Name -like 'actions.runner.*' } | Select-Object -First 1 +if ($SkipRegistration) { + Info '' + Info 'Toolchain installed. To finish, run this from an ELEVATED PowerShell' + Info 'on the machine itself -- config.cmd prompts for the account password on' + Info 'an interactive console, which a remote or scripted session cannot answer:' + Info '' + Info " `$env:GITHUB_PAT = ''" + Info " $($MyInvocation.MyCommand.Path) -ServiceAccount '$ServiceAccount' -SkipToolchain" + Info '' + exit 0 +} if ($svc) { Info "Restarting $($svc.Name) so it picks up the machine environment" Restart-Service $svc.Name From 2b0d6c96c5584d6c044428e30ec42eb697d07242 Mon Sep 17 00:00:00 2001 From: Riccardo Persello Date: Mon, 7 Sep 2026 19:02:30 +0200 Subject: [PATCH 05/13] feat(windows): let gh mint the registration token, so no PAT is needed Registration now tries -RegistrationToken, then -Pat, then the GitHub CLI. The gh path is the one worth having: its credential is managed and revocable rather than a classic PAT pasted through a shell, and there is nothing to create beforehand. gh is installed as part of the toolchain to make it available, and is useful on a CI box regardless. It needs one grant. gh's ordinary login carries read:org while registering an org runner needs admin:org, so the failure path prints `gh auth refresh -h github.com -s admin:org` verbatim, and notes that a repo-scoped runner wants repo admin instead -- and that an org which disables repo-level runners reports that as a 404 rather than a permission error, which is not guessable. Three bugs found by running it, not reading it: The credential guard predated the gh fallback and rejected the very case it was meant to allow, so the gh path was unreachable. It now treats gh as a credential, and only insists on one when the toolchain step is also skipped. Under this script's ErrorActionPreference of 'Stop', `2>&1` on a native command throws a NativeCommandError, so gh's message aborted the script instead of reaching the guidance written for it. Relaxed around that one call. Even captured, `2>&1` yields ErrorRecords whose formatting wraps PowerShell's own "At