From d046577c8e99f90034aadbbd988e966e5497c5d1 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Tue, 5 May 2026 10:52:08 +0200 Subject: [PATCH] ci(windows): make provision.ps1 self-sufficient (dedicated user, parser-bug fix); refresh stale workflow comments --- .gitea/workflows/build-windows.yml | 35 +++-- ci/runners/windows/provision.ps1 | 233 ++++++++++++++++++++++------- 2 files changed, 199 insertions(+), 69 deletions(-) diff --git a/.gitea/workflows/build-windows.yml b/.gitea/workflows/build-windows.yml index 57ed121c4..9e826bfbd 100644 --- a/.gitea/workflows/build-windows.yml +++ b/.gitea/workflows/build-windows.yml @@ -87,9 +87,10 @@ jobs: - name: Ensure Rust toolchain configured for runner user shell: pwsh run: | - # provision.ps1 ran rustup as an admin user; act_runner runs as LocalSystem - # which has its own (empty) $RUSTUP_HOME. Bootstrap the default toolchain - # for LocalSystem on first run -- subsequent runs are no-ops. + # provision.ps1 installs rust machine-wide (CARGO_HOME=C:\cargo, + # RUSTUP_HOME=C:\rustup) so this is normally a no-op verification. + # Kept as a guardrail in case the runner was provisioned with an + # older script or by hand. rustup --version rustup toolchain install $env:RUST_VERSION --profile minimal --component rustfmt if ($LASTEXITCODE -ne 0) { throw "rustup toolchain install failed ($LASTEXITCODE)" } @@ -102,9 +103,9 @@ jobs: - name: Install flutter_rust_bridge codegen tools shell: pwsh run: | - # Pin the install destination with --root so we don't have to guess at - # the runner's $USERPROFILE (act_runner runs as LocalSystem on this host, - # which makes $USERPROFILE a non-obvious system path). + # Pin the install destination with --root so the resulting binaries land + # in a deterministic, machine-wide path regardless of which user the + # runner service is configured to run as. $tools = 'C:\cargo-tools' New-Item -ItemType Directory -Force -Path "$tools\bin" | Out-Null @@ -257,14 +258,13 @@ jobs: fi fi - # The monolithic `python build.py` invocation that the upstream GitHub - # workflow uses runs cargo and `flutter build windows` back-to-back. On this - # self-hosted runner, *something* during the ~14min cargo build wipes out - # flutter/windows, which then makes the subsequent flutter build fail with - # "source directory does not exist" -- even though the diagnostic step - # immediately above confirms the dir is fully present at that moment. - # Splitting the build into discrete steps lets us restore flutter/windows - # right before flutter needs it, after cargo has finished. + # The upstream GitHub workflow runs `python build.py` which chains cargo + + # `flutter build windows` in one step. We split that here so we can: + # - get a checkpoint between cargo and flutter (~14 min apart) + # - restore flutter/windows from git index if anything has clobbered it + # (early in our self-hosted bring-up, flutter/windows occasionally + # vanished during the cargo phase under LocalSystem; the restore is + # defensive and a no-op once the runner is on a normal user) - name: Cargo build (virtual_display dylib + main lib) shell: pwsh @@ -282,10 +282,9 @@ jobs: throw "target\release\librustdesk.dll missing after cargo build" } - # Restore + build are intentionally one step. flutter/windows has been - # observed to disappear between steps on this runner (cause unidentified -- - # likely Windows Defender quarantine, possibly act_runner workspace handling). - # Keeping them atomic eliminates any window of opportunity for that to happen. + # Restore + build are intentionally one step. Closes any potential window + # for flutter/windows to vanish between a separate restore step and the + # build, regardless of root cause. - name: Flutter build windows (with last-second restore) shell: pwsh run: | diff --git a/ci/runners/windows/provision.ps1 b/ci/runners/windows/provision.ps1 index f856ea793..a82affcd8 100644 --- a/ci/runners/windows/provision.ps1 +++ b/ci/runners/windows/provision.ps1 @@ -1,26 +1,40 @@ -# Provisions a Windows Server 2022 host as a Gitea Actions runner for RustDesk -# desktop builds (flutter x64 + sciter x86). Idempotent: safe to re-run. +# Provisions a Windows host (Windows 10/11 or Server 2019+) as a Gitea Actions +# runner for RustDesk desktop builds. Idempotent: safe to re-run. # -# Versions are pinned to .github/workflows/flutter-build.yml. Bump them there -# and here together. +# Versions are pinned to .gitea/workflows/build-windows.yml. Bump them there and +# here together. # # Usage (Administrator PowerShell): # Set-ExecutionPolicy -Scope Process Bypass -Force # .\provision.ps1 -GiteaUrl https://gitea.example.com -RunnerToken +# +# By default the runner service is created under a dedicated local user +# (`gitea-runner`) -- LocalSystem has been observed to break flutter pub get, +# symlink creation, and git's "dubious ownership" check on this codebase. To +# opt out, pass `-ServiceAccount LocalSystem` (not recommended). [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string] $GiteaUrl, [Parameter(Mandatory = $true)] [string] $RunnerToken, - [string] $RunnerName = "$env:COMPUTERNAME-rustdesk", - [string] $RunnerLabels = "windows-10,self-hosted,X64", - [string] $RunnerVersion = "0.2.11" + [string] $RunnerName = "$env:COMPUTERNAME-rustdesk", + [string] $RunnerLabels = "windows-10,self-hosted,X64", + [string] $RunnerVersion = "0.2.11", + [string] $ServiceAccount = "gitea-runner", + [SecureString] $ServiceAccountPassword ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' -# --- pinned versions (mirror flutter-build.yml env block) --- +# Must run elevated -- nearly every step writes Machine env, HKLM, or service config. +$me = [Security.Principal.WindowsIdentity]::GetCurrent() +if (-not (New-Object Security.Principal.WindowsPrincipal $me).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this script in an elevated (Administrator) PowerShell session.' +} + +# --- pinned versions (mirror .gitea/workflows/build-windows.yml env block) --- $RUST_VERSION = '1.75.0' $RUST_NIGHTLY = 'nightly-2023-10-13' $LLVM_VERSION = '15.0.6' @@ -30,15 +44,18 @@ $VCPKG_COMMIT = '120deac3062162151622ca4860575a33844ba10b' $ToolsRoot = 'C:\tools' New-Item -ItemType Directory -Force -Path $ToolsRoot | Out-Null +# Exact-segment-match version of PATH augmentation. Substring matching would +# falsely find C:\bin when C:\binaries is on PATH. function Add-MachinePath([string]$Dir) { $cur = [Environment]::GetEnvironmentVariable('Path', 'Machine') - if ($cur -notlike "*$Dir*") { + $segments = $cur -split ';' | Where-Object { $_ } + if ($segments -notcontains $Dir) { [Environment]::SetEnvironmentVariable('Path', "$cur;$Dir", 'Machine') } - if ($env:Path -notlike "*$Dir*") { $env:Path = "$env:Path;$Dir" } + if (($env:Path -split ';') -notcontains $Dir) { $env:Path = "$env:Path;$Dir" } } -# --- 1. Chocolatey (used for git, python, nuget, 7zip) --- +# --- 1. Chocolatey (used for git, python, nuget, 7zip, node, dotnet, ...) --- if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { Write-Host '==> Installing Chocolatey' Set-ExecutionPolicy Bypass -Scope Process -Force @@ -47,14 +64,14 @@ if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { } Write-Host '==> Installing base packages' -# nodejs-lts: act_runner needs node to execute JavaScript actions. +# nodejs-lts: act_runner spawns Node to execute JavaScript actions. # powershell-core: workflows use `shell: pwsh` (PS 7), not the OS's PS 5.1. -# dotnet-sdk: required for WiX 4 SDK-style projects (.wixproj) used by the MSI build. +# dotnet-sdk: WiX 4 SDK-style projects (.wixproj) need it for the MSI build. choco install -y --no-progress ` git python311 nuget.commandline 7zip cmake ninja ` nodejs-lts powershell-core dotnet-sdk Add-MachinePath 'C:\Program Files\Git\cmd' -Add-MachinePath 'C:\Program Files\Git\bin' # bash.exe + posix tools (sed, find, etc.) +Add-MachinePath 'C:\Program Files\Git\bin' # bash.exe + posix tools (sed, find, ...) Add-MachinePath 'C:\Python311' Add-MachinePath 'C:\Python311\Scripts' Add-MachinePath 'C:\Program Files\nodejs' @@ -62,13 +79,17 @@ Add-MachinePath 'C:\Program Files\PowerShell\7' Add-MachinePath 'C:\Program Files\dotnet' # --- 2. Visual Studio 2022 Build Tools (MSVC v143 + Win10 SDK) --- -$vsInstaller = "$env:ProgramFiles(x86)\Microsoft Visual Studio\Installer\vswhere.exe" -$vsPresent = (Test-Path $vsInstaller) -and ((& $vsInstaller -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath) -ne $null) +# Use [Environment]::GetEnvironmentVariable to avoid the PowerShell parser quirk +# that mis-tokenises `$env:ProgramFiles(x86)` as `$env:ProgramFiles` + `(x86)`. +$pfx86 = [Environment]::GetEnvironmentVariable('ProgramFiles(x86)') +$vsInstaller = Join-Path $pfx86 'Microsoft Visual Studio\Installer\vswhere.exe' +$vsPresent = (Test-Path $vsInstaller) -and ` + ((& $vsInstaller -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath) -ne $null) if (-not $vsPresent) { Write-Host '==> Installing VS 2022 Build Tools (this takes a while)' $vsBootstrapper = "$env:TEMP\vs_buildtools.exe" Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vs_buildtools.exe' -OutFile $vsBootstrapper - $args = @( + $vsArgs = @( '--quiet','--wait','--norestart','--nocache', '--add','Microsoft.VisualStudio.Workload.VCTools', '--add','Microsoft.VisualStudio.Component.VC.Tools.x86.x64', @@ -77,14 +98,14 @@ if (-not $vsPresent) { '--add','Microsoft.VisualStudio.Component.VC.CMake.Project', '--includeRecommended' ) - $p = Start-Process -FilePath $vsBootstrapper -ArgumentList $args -Wait -PassThru + $p = Start-Process -FilePath $vsBootstrapper -ArgumentList $vsArgs -Wait -PassThru if ($p.ExitCode -notin 0,3010) { throw "VS Build Tools installer exit $($p.ExitCode)" } } -# --- 3. Rust (stable 1.75 + nightly-2023-10-13 with i686 target) --- -# Install machine-wide so the act_runner service (LocalSystem) and any admin user -# share the same toolchain registry. Without this, rustup state lives in the -# installing user's profile and LocalSystem ends up with no default toolchain. +# --- 3. Rust (stable + nightly with i686 target) --- +# Install machine-wide so any user (including the dedicated runner account) +# shares one toolchain registry. Without this, rustup state lives in the +# installing user's profile and the service user has no default toolchain. $rustupHome = 'C:\rustup' $cargoHome = 'C:\cargo' [Environment]::SetEnvironmentVariable('RUSTUP_HOME', $rustupHome, 'Machine') @@ -104,7 +125,7 @@ rustup toolchain install $RUST_NIGHTLY --profile minimal --component rustfmt rustup target add --toolchain $RUST_NIGHTLY i686-pc-windows-msvc rustup default $RUST_VERSION -# --- 4. LLVM/Clang 15.0.6 (matches KyleMayes/install-llvm-action) --- +# --- 4. LLVM/Clang (matches KyleMayes/install-llvm-action layout) --- $llvmDir = "$ToolsRoot\llvm-$LLVM_VERSION" if (-not (Test-Path "$llvmDir\bin\clang.exe")) { Write-Host "==> Installing LLVM $LLVM_VERSION" @@ -115,7 +136,7 @@ if (-not (Test-Path "$llvmDir\bin\clang.exe")) { [Environment]::SetEnvironmentVariable('LIBCLANG_PATH', "$llvmDir\bin", 'Machine') Add-MachinePath "$llvmDir\bin" -# --- 5. Flutter 3.24.5 (stable channel, with windows precache) --- +# --- 5. Flutter (stable channel, with windows precache) --- $flutterDir = "$ToolsRoot\flutter" if (-not (Test-Path "$flutterDir\bin\flutter.bat")) { Write-Host "==> Installing Flutter $FLUTTER_VERSION" @@ -141,7 +162,102 @@ Pop-Location [Environment]::SetEnvironmentVariable('VCPKG_ROOT', $vcpkgDir, 'Machine') Add-MachinePath $vcpkgDir -# --- 7. Gitea act_runner --- +# --- 7. CI prerequisites that aren't tools, but environmental switches --- + +# git's "dubious ownership" check (>= 2.35.2) refuses to operate on a repo whose +# .git directory is owned by a different user than the one running git. The +# Flutter SDK at C:\tools\flutter is provisioned by this script as Administrator +# but the runner service runs as a non-admin user. Trust everything system-wide. +git config --system --add safe.directory '*' 2>$null + +# Flutter on Windows needs SeCreateSymbolicLinkPrivilege to build plugins. +# Enable Developer Mode (registry) AND grant the privilege via Local Security +# Policy to the built-in "Users" group (SID S-1-5-32-545). Either alone has been +# observed to not take effect until logon-token refresh; doing both is +# belt-and-suspenders. The privilege only reaches a long-running service after +# a reboot or a fresh service-token issuance. +$devKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' +if (-not (Test-Path $devKey)) { New-Item -Path $devKey -Force | Out-Null } +New-ItemProperty -Path $devKey -Name 'AllowDevelopmentWithoutDevLicense' ` + -PropertyType DWORD -Value 1 -Force | Out-Null + +$secCfg = "$env:TEMP\sec-symlink.cfg" +secedit /export /cfg $secCfg | Out-Null +$secContent = Get-Content $secCfg -Raw +if ($secContent -match 'SeCreateSymbolicLinkPrivilege\s*=\s*([^\r\n]*)') { + $cur = $matches[1] + if ($cur -notmatch '\*S-1-5-32-545') { + $secContent = $secContent -replace ` + '(SeCreateSymbolicLinkPrivilege\s*=\s*)([^\r\n]*)', ` + '$1$2,*S-1-5-32-545' + } +} else { + $secContent = $secContent -replace ` + '(\[Privilege Rights\][\r\n]+)', ` + "`$1SeCreateSymbolicLinkPrivilege = *S-1-5-32-545`r`n" +} +$secContent | Set-Content $secCfg +secedit /configure /db "$env:TEMP\sec-symlink.sdb" /cfg $secCfg /areas USER_RIGHTS /quiet +Remove-Item $secCfg, "$env:TEMP\sec-symlink.sdb" -ErrorAction SilentlyContinue + +# --- 8. Dedicated runner user --- +# Running as LocalSystem causes a cascade of issues: +# - $USERPROFILE = C:\Windows\System32\config\systemprofile, which Flutter, +# dart pub, and other POSIX-leaning tools mis-handle. +# - cargo install lands binaries in that systemprofile path -> not on PATH. +# - flutter/windows occasionally vanishes during long cargo builds. +# A normal local user fixes all of these. +if ($ServiceAccount -ne 'LocalSystem') { + if (-not (Get-LocalUser -Name $ServiceAccount -ErrorAction SilentlyContinue)) { + if (-not $ServiceAccountPassword) { + # Generate a 32-byte random password using the OS RNG. Encoded as + # base64 (alphanumeric + +/) and trimmed of padding -- meets local + # password complexity without needing System.Web (which is missing + # on Server Core). + $bytes = New-Object byte[] 24 + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) + $plain = ([Convert]::ToBase64String($bytes)).TrimEnd('=') + 'A1!' + $ServiceAccountPassword = ConvertTo-SecureString $plain -AsPlainText -Force + Remove-Variable plain, bytes + } + Write-Host "==> Creating local user '$ServiceAccount'" + New-LocalUser -Name $ServiceAccount -Password $ServiceAccountPassword ` + -PasswordNeverExpires -AccountNeverExpires ` + -Description 'Gitea Actions runner service account' | Out-Null + Add-LocalGroupMember -Group 'Users' -Member $ServiceAccount + } + + # Grant "Log on as a service" via secedit (no PS native cmdlet for this). + $sid = (Get-LocalUser $ServiceAccount).SID.Value + $svcCfg = "$env:TEMP\sec-svc.cfg" + secedit /export /cfg $svcCfg | Out-Null + $svcContent = Get-Content $svcCfg -Raw + if ($svcContent -match "SeServiceLogonRight\s*=\s*([^\r\n]*)") { + if ($matches[1] -notmatch [regex]::Escape($sid)) { + $svcContent = $svcContent -replace ` + '(SeServiceLogonRight\s*=\s*)([^\r\n]*)', ` + "`$1`$2,*$sid" + } + } else { + $svcContent = $svcContent -replace ` + '(\[Privilege Rights\][\r\n]+)', ` + "`$1SeServiceLogonRight = *$sid`r`n" + } + $svcContent | Set-Content $svcCfg + secedit /configure /db "$env:TEMP\sec-svc.sdb" /cfg $svcCfg /areas USER_RIGHTS /quiet + Remove-Item $svcCfg, "$env:TEMP\sec-svc.sdb" -ErrorAction SilentlyContinue + + # Ensure the user can read/write everything it needs for builds. + foreach ($p in @('C:\actions-runner','C:\cargo','C:\cargo-tools','C:\vcpkg','C:\vcpkg-cache')) { + New-Item -ItemType Directory -Force -Path $p | Out-Null + icacls $p /grant "${ServiceAccount}:(OI)(CI)F" /T 2>$null | Out-Null + } + foreach ($p in @('C:\rustup','C:\tools')) { + if (Test-Path $p) { icacls $p /grant "${ServiceAccount}:(OI)(CI)RX" /T 2>$null | Out-Null } + } +} + +# --- 9. Gitea act_runner --- $runnerDir = 'C:\actions-runner' New-Item -ItemType Directory -Force -Path $runnerDir | Out-Null $runnerExe = "$runnerDir\act_runner.exe" @@ -159,35 +275,50 @@ if (-not (Test-Path "$runnerDir\.runner")) { --name $RunnerName ` --labels $RunnerLabels } -if (-not (Get-Service -Name 'gitea-act-runner' -ErrorAction SilentlyContinue)) { + +# Reconfigure the service every run so re-running with a different +# -ServiceAccount actually takes effect. +$svc = Get-Service -Name 'gitea-act-runner' -ErrorAction SilentlyContinue +if ($svc) { + if ($svc.Status -eq 'Running') { Stop-Service gitea-act-runner } +} else { Write-Host '==> Installing runner as Windows service' - # nssm is the cleanest way to host a console exe as a service on Windows. choco install -y --no-progress nssm - nssm install gitea-act-runner $runnerExe daemon - nssm set gitea-act-runner AppDirectory $runnerDir - nssm set gitea-act-runner Start SERVICE_AUTO_START - nssm set gitea-act-runner AppStdout "$runnerDir\runner.log" - nssm set gitea-act-runner AppStderr "$runnerDir\runner.log" + nssm install gitea-act-runner $runnerExe daemon | Out-Null +} +nssm set gitea-act-runner AppDirectory $runnerDir | Out-Null +nssm set gitea-act-runner Start SERVICE_AUTO_START | Out-Null +nssm set gitea-act-runner AppStdout "$runnerDir\runner.log" | Out-Null +nssm set gitea-act-runner AppStderr "$runnerDir\runner.log" | Out-Null + +if ($ServiceAccount -eq 'LocalSystem') { + nssm set gitea-act-runner ObjectName 'LocalSystem' | Out-Null +} else { + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($ServiceAccountPassword) + try { + $plain = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($bstr) + nssm set gitea-act-runner ObjectName ".\$ServiceAccount" $plain | Out-Null + } finally { + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + Remove-Variable plain -ErrorAction SilentlyContinue + } +} + +# Start may fail before reboot if the new SeServiceLogonRight hasn't reached +# SCM yet -- that's expected; the service will start cleanly after reboot. +try { + Start-Service gitea-act-runner +} catch { + Write-Warning "Could not start gitea-act-runner now ($($_.Exception.Message)). It will start on reboot." } -Start-Service gitea-act-runner Pop-Location -# --- 8. CI prerequisites that aren't tools, but are environmental switches --- - -# git's "dubious ownership" check (>= 2.35.2) refuses to operate on a repo whose -# .git directory is owned by a different user than the one running git. -# C:\tools\flutter is provisioned by this script (running as admin) but the -# runner service runs as a non-admin user. Trust everything system-wide. -git config --system --add safe.directory '*' 2>$null - -# Flutter on Windows needs SeCreateSymbolicLinkPrivilege to build plugins. -# Enabling Developer Mode grants this to all non-admin users without per-user -# secedit gymnastics. -$devKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' -if (-not (Test-Path $devKey)) { New-Item -Path $devKey -Force | Out-Null } -New-ItemProperty -Path $devKey -Name 'AllowDevelopmentWithoutDevLicense' ` - -PropertyType DWORD -Value 1 -Force | Out-Null - Write-Host '' -Write-Host '==> Done. Reboot recommended so PATH/env changes take effect for the runner service.' -Write-Host ' After reboot, verify the runner shows up green in Gitea > Site Admin > Actions > Runners.' +Write-Host '==> Done.' +Write-Host ' A reboot is REQUIRED before the first build run, so:' +Write-Host ' - the runner service inherits the new SeCreateSymbolicLinkPrivilege token' +Write-Host ' - all PATH/env changes propagate to the SCM-launched service' +Write-Host ' After reboot, verify the runner shows up in Gitea > Site Admin > Actions > Runners.' +if ($ServiceAccount -eq 'LocalSystem') { + Write-Warning 'Service is running as LocalSystem. RustDesk builds have been observed to fail in this configuration (Flutter pub get, symlinks, dubious ownership). Re-run with -ServiceAccount gitea-runner to switch.' +}