diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73659bb32..228ba8fec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,8 @@ jobs: run: bash scripts/test-agent-work.sh - name: scripts/runner-setup.sh (private-repo check) run: sh scripts/test-runner-setup.sh + - name: scripts/runner-setup.ps1 (private-repo check, dry run under pwsh) + run: sh scripts/test-runner-setup-windows.sh test: name: Tests (${{ matrix.name }}) diff --git a/docs/AGENT_WORK.md b/docs/AGENT_WORK.md index d590ced97..ff362fd6a 100644 --- a/docs/AGENT_WORK.md +++ b/docs/AGENT_WORK.md @@ -108,7 +108,7 @@ bootstrap from a trusted checkout. It creates the non-sudo `fizzy-worker` accoun Zig at the version in `scripts/cloud-setup.sh`, jj, gh, git, zstd and Linux build/test libraries, restores the published Zig dependencies, and registers an Actions runner with the `fizzy` label. It installs the runner's systemd service as `fizzy-worker`, plus a daily cleanup timer. -macOS and Windows provisioning are separate follow-up work. +macOS provisioning is separate follow-up work; Windows is below. ```sh # Preview on any machine: no root, token, downloads or system changes needed. @@ -189,3 +189,89 @@ sudo -u fizzy-worker -H /usr/local/sbin/fizzy-runner-clean --dry-run Each removal logs its estimated freed KiB and the sweep logs a total. The timer skips active jobs rather than stopping them; the next daily sweep retries. It never sweeps unrelated users' projects or deletes unregistered workspace directories. + +### Self-hosted runner (Windows) + +`scripts/runner-setup.ps1` does for a dedicated **Windows 11** machine (x64 or arm64) what +`runner-setup.sh` does for Ubuntu, with the same rules: private repositories only (checked with +`gh api`, refused otherwise, `FIZZY_RUNNER_ALLOW_UNCHECKED=1` to skip), **never fork PRs**, label +`fizzy`, `RUNNER_URL`/`RUNNER_NAME`/`RUNNER_LABELS`/`ZIG_VERSION`/`JJ_VERSION`/`RUNNER_VERSION`/ +`DEPS_CACHE_URL` as for Ubuntu, and the Zig version read from `cloud-setup.sh`. + +**Prerequisites** (installed by you, once): Git for Windows (its `bash` is what workflows use) and +the GitHub CLI logged in as someone who can read `fizzyedit/work`. A real run needs the login for +the private check; `-DryRun` does not (without one it prints that a real run would refuse). + +```powershell +winget install Git.Git GitHub.cli +gh auth login +``` + +**Run** from an elevated PowerShell (Windows PowerShell 5.1 or pwsh) in a trusted checkout, keeping +`runner-setup.ps1`, `runner-clean.ps1` and `cloud-setup.sh` together. Windows' default Restricted +execution policy refuses script files, so every run names the policy for that one process: + +```powershell +# Preview: no elevation, token, downloads or changes. +powershell -ExecutionPolicy Bypass -File .\scripts\runner-setup.ps1 -DryRun + +# Token: Settings > Actions > Runners > New self-hosted runner in fizzyedit/work, the value after +# `--token` in the configure step. It lasts about an hour. Reading it as a secure string keeps it +# out of history and command arguments. +$s = Read-Host 'Runner registration token' -AsSecureString +$env:RUNNER_TOKEN = [Net.NetworkCredential]::new('', $s).Password +powershell -ExecutionPolicy Bypass -File .\scripts\runner-setup.ps1 +Remove-Item Env:\RUNNER_TOKEN +``` + +What it does: creates the local user `fizzy-worker` (not an administrator, random password it +never writes anywhere but the service's own credential store) and grants it the batch-logon right +the daily task needs. It lays out `C:\fizzy` with inheritance removed, so only SYSTEM and +Administrators have access by default; `fizzy-worker` gets Modify only on `actions-runner`, +`workspaces` and `zig-cache`, and read/execute on `tools` (Zig, jj; on the machine PATH, so +admin-write only) and `bin`. It enables long paths, excludes `C:\fizzy` from Defender scanning +(`FIZZY_RUNNER_NO_DEFENDER_EXCLUSION=1` skips), installs Zig and jj, restores the deps cache into +`C:\fizzy\zig-cache`, registers the runner as a service, and installs the task `FizzyRunnerClean` +(daily 04:00 plus up to 30 minutes, as `fizzy-worker`), which it runs once and checks. The runner's +`.env` carries `FIZZY_BUILD_SLOTS`, `FIZZY_BUILD_MAX_LOAD`, `ZIG_GLOBAL_CACHE_DIR` and a job `PATH` +with Git for Windows' `bash` ahead of System32's WSL launcher (a service reads the machine PATH only +when Windows boots, hence `.env`). `runner-clean.ps1 -DryRun` previews the sweep: it removes only +untracked `.zig-cache`, `zig-out` and `zig-pkg` trees under `C:\fizzy\workspaces` and +`actions-runner\_work`, vetoed by any running zig, zls or Runner.Worker, never following symlinks +or junctions. The enrolled-merged-PR workspace deletion of the Ubuntu cleaner is not ported. + +Downloads run as an administrator, so they are checked: Zig against the sha256 in +ziglang.org's `index.json` (or `ZIG_SHA256`), the Actions runner against the digest GitHub +publishes, jj and the default deps cache against the asset digest GitHub records where there is one +(otherwise a warning says so), zstd pinned in the script (v1.5.7; it publishes no digest; `ZSTD_VERSION` and `ZSTD_SHA256` override), and Zig and the runner +refused unverified. A `DEPS_CACHE_URL` other than the default needs `DEPS_CACHE_SHA256`. `latest` lookups use the +anonymous GitHub API (60 an hour); the script says so if it is rate limited. The Linux deps archive +is unpacked with `tar.exe`; if that fails (paths, symlinks) setup stops, unless +`FIZZY_RUNNER_DEPS_PARTIAL_OK=1` accepts a reported partial restore. A failed run can be repeated +with a fresh token: a half-registered runner is cleared and registered again with `--replace`. + +**Service, not an interactive session.** The runner is a Windows service running as +`fizzy-worker`: it survives reboots and logouts with no stored logon, and the account has no +administrator rights. A service runs in session 0, with no desktop, so tests that open a real +window cannot run on it. The headless gates (`zig build test`, `test-integration`) do not need +one. If GUI tests become a job, the way is an auto-logon of a dedicated user and the runner started +by a logon Scheduled Task instead of the service (`config.cmd` without `--runasservice`); that +keeps a password in the registry and an always-logged-in desktop, so it is not done here until +there is such a job. + +**Verify** (the maintainer; nothing here has run on real Windows): + +1. `gh api repos/fizzyedit/work/actions/runners --jq '.runners[]|[.name,.status,([.labels[].name]|join(","))]|@tsv'` + shows the machine `online` with the `fizzy` label (or Settings > Actions > Runners). +2. `Get-Service actions.runner.*` is `Running`; `whoami /groups` as `fizzy-worker` lacks + Administrators; `icacls C:\fizzy\tools` shows no `Users` or `fizzy-worker` write. +3. In a new shell, `zig version` equals the default in `cloud-setup.sh`; `jj --version` works. +4. Run one `zig build test` through the runner: a workflow in `fizzyedit/work` with + `runs-on: [self-hosted, Windows, fizzy]` and `scripts/zb build test` (bash from Git for + Windows). It should find the dependencies in the cache without refetching. +5. `Get-ScheduledTaskInfo FizzyRunnerClean` gives `LastTaskResult` 0 (setup already checks once). + +Known unknowns: `scripts/zb` is POSIX `sh` using symlinks and `/proc/loadavg`; under Git Bash the +slot symlinks and the load ceiling may not work (the slot count still bounds builds if they do). +CI runs the dry-run test under pwsh 7 only; Windows PowerShell 5.1 (native-command stderr, no +`??`) is handled by pattern, not tested. diff --git a/scripts/runner-clean.ps1 b/scripts/runner-clean.ps1 new file mode 100644 index 000000000..33da73a3d --- /dev/null +++ b/scripts/runner-clean.ps1 @@ -0,0 +1,158 @@ +# Clean only this dedicated worker's disposable build data (.zig-cache, zig-out, zig-pkg) that its +# jj/git checkouts do not track. Mirrors scripts/runner-clean.sh; the opt-in deletion of merged +# PR workspaces (the *.pr enrollment) is not ported to Windows. +[CmdletBinding()] +param([switch]$DryRun) +Set-StrictMode -Version 2 +$ErrorActionPreference = 'Stop' +$root = if ($env:FIZZY_WORKER_ROOT) { $env:FIZZY_WORKER_ROOT } else { 'C:\fizzy' } +$cacheNames = @('.zig-cache', 'zig-out', 'zig-pkg') + +if ($DryRun -and $env:OS -ne 'Windows_NT') { + Write-Host "Would inspect running zig, zls and Runner.Worker processes." + Write-Host "Would clean untracked caches under $root\workspaces and $root\actions-runner\_work." + exit 0 +} +if (-not (Test-Path $root -PathType Container)) { exit 0 } + +# One sweep at a time; the lock is released if this process dies. +# (A dry run creates nothing.) The lock lives in workspaces, the one place the worker may write. +$lock = $null +if (-not $DryRun) { + try { + $lock = [IO.File]::Open("$root\workspaces\.runner-clean.lock", 'OpenOrCreate', 'ReadWrite', 'None') + } catch { exit 0 } +} + +# A whole-sweep veto also protects builds whose compiler's cwd is outside the project. +function Test-Busy { + $p = Get-Process -Name zig, zls, Runner.Worker -ErrorAction SilentlyContinue + if ($p) { Write-Host "Busy process $($p[0].Id) ($($p[0].Name)); retaining all data"; return $true } + return $false +} +if (Test-Busy) { exit 0 } + +function Test-Reparse($path) { + return ([IO.File]::GetAttributes($path) -band [IO.FileAttributes]::ReparsePoint) -ne 0 +} +function Long($path) { if ($path.StartsWith('\\?\')) { $path } else { '\\?\' + $path } } + +# Walk a base: collect repositories (a directory holding .git or .jj) and cache directories, +# never entering caches, repository metadata or reparse points (symlinks and junctions). +function Find-Items($base) { + $repos = New-Object Collections.Generic.List[string] + $caches = New-Object Collections.Generic.List[string] + $stack = New-Object Collections.Generic.Stack[string] + $stack.Push($base) + while ($stack.Count -gt 0) { + $dir = $stack.Pop() + if ((Test-Path -LiteralPath "$dir\.git") -or (Test-Path -LiteralPath "$dir\.jj")) { $repos.Add($dir) } + try { $children = [IO.Directory]::GetDirectories((Long $dir)) } catch { continue } + foreach ($c in $children) { + $c = $c -replace '^\\\\\?\\', '' + $leaf = Split-Path -Leaf $c + if ($leaf -eq '.git' -or $leaf -eq '.jj') { continue } + if (Test-Reparse (Long $c)) { continue } + if ($cacheNames -contains $leaf) { $caches.Add($c); continue } + $stack.Push($c) + } + } + return @{ Repos = $repos; Caches = $caches } +} + +# Windows PowerShell 5.1 turns native stderr into a terminating error under 'Stop'; run natives under 'Continue'. +$nativeExit = 0 +function Native([scriptblock]$cmd) { + $old = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { $out = & $cmd; $code = $LASTEXITCODE } finally { $ErrorActionPreference = $old } + $script:nativeExit = $code + return $out +} +function Get-Tracked($repo) { + # A successful, non-empty tracked-file query is required; a parent repo is not evidence. + Push-Location $repo + try { + if (Test-Path -LiteralPath "$repo\.jj") { $out = Native { & jj --ignore-working-copy file list 2>$null } } + else { $out = Native { & git ls-files 2>$null } } + if ($nativeExit -ne 0 -or -not $out) { return $null } + return @($out | ForEach-Object { ($_ -replace '\\', '/').Trim('"') }) + } catch { return $null } finally { Pop-Location } +} + +# Walk a tree without following reparse points (symlinks, junctions). Returns the links found, +# the size, and whether any repository metadata (.git or .jj) is inside. +function Scan-Tree($path) { + $links = New-Object Collections.Generic.List[string] + $bytes = [long]0 + $meta = $false + $readonly = New-Object Collections.Generic.List[string] + $stack = New-Object Collections.Generic.Stack[string] + $stack.Push($path) + while ($stack.Count -gt 0) { + $dir = $stack.Pop() + try { $files = [IO.Directory]::GetFiles((Long $dir)) } catch { $files = @() } + foreach ($f in $files) { + $f = $f -replace '^\\\\\?\\', '' + if (Test-Reparse (Long $f)) { $links.Add($f); continue } + if ((Split-Path -Leaf $f) -in '.git', '.jj') { $meta = $true } + try { + $fi = New-Object IO.FileInfo (Long $f) + $bytes += $fi.Length + if ($fi.IsReadOnly) { $readonly.Add($f) } + } catch {} + } + try { $dirs = [IO.Directory]::GetDirectories((Long $dir)) } catch { $dirs = @() } + foreach ($d in $dirs) { + $d = $d -replace '^\\\\\?\\', '' + if (Test-Reparse (Long $d)) { $links.Add($d); continue } + if ((Split-Path -Leaf $d) -in '.git', '.jj') { $meta = $true; continue } + $stack.Push($d) + } + } + return @{ Links = $links; Bytes = $bytes; Meta = $meta; ReadOnly = $readonly } +} + +$freed = 0 +function Remove-Cache($path, $scan) { + if (Test-Busy) { return } + $kb = [long]($scan.Bytes / 1KB) + if ($DryRun) { Write-Host "Would remove $path ($kb KiB, $($scan.Links.Count) links)"; return } + try { + # .NET Framework (Windows PowerShell 5.1) may follow links in a recursive delete, so each + # link goes first, by itself, and only then the now link-free tree. + foreach ($l in $scan.Links) { + if ([IO.Directory]::Exists((Long $l))) { [IO.Directory]::Delete((Long $l), $false) } else { [IO.File]::Delete((Long $l)) } + } + foreach ($f in $scan.ReadOnly) { [IO.File]::SetAttributes((Long $f), [IO.FileAttributes]::Normal) } + [IO.Directory]::Delete((Long $path), $true) + $script:freed += $kb + Write-Host "Removed $path ($kb KiB)" + } catch { Write-Host "Cannot remove $path ($_); retaining" } +} + +foreach ($base in "$root\workspaces", "$root\actions-runner\_work") { + if (-not (Test-Path -LiteralPath $base -PathType Container) -or (Test-Reparse $base)) { continue } + $found = Find-Items $base + $trackedByRepo = @{} + foreach ($cache in $found.Caches) { + # The nearest enclosing repository decides what is tracked. + $repo = $null + foreach ($r in ($found.Repos | Sort-Object Length -Descending)) { + if ($cache.StartsWith($r + '\', [StringComparison]::OrdinalIgnoreCase)) { $repo = $r; break } + } + if (-not $repo) { Write-Host "Retaining $cache (no repository)"; continue } + if (-not $trackedByRepo.ContainsKey($repo)) { $trackedByRepo[$repo] = Get-Tracked $repo } + $tracked = $trackedByRepo[$repo] + if ($null -eq $tracked) { Write-Host "Retaining $cache (cannot list tracked files)"; continue } + $relative = $cache.Substring($repo.Length + 1) -replace '\\', '/' + if ($tracked | Where-Object { $_ -eq $relative -or $_.StartsWith($relative + '/') }) { + Write-Host "Retaining tracked cache $cache"; continue + } + $scan = Scan-Tree $cache + if ($scan.Meta) { Write-Host "Retaining cache containing repository metadata $cache"; continue } + Remove-Cache $cache $scan + } +} +Write-Host "Cleanup freed $freed KiB" +if ($lock) { $lock.Dispose() } diff --git a/scripts/runner-setup.ps1 b/scripts/runner-setup.ps1 new file mode 100644 index 000000000..0b0f34b86 --- /dev/null +++ b/scripts/runner-setup.ps1 @@ -0,0 +1,447 @@ +# Provision a dedicated Windows 11 Actions worker. Run from an elevated PowerShell (5.1 or newer); +# -DryRun needs no elevation and runs on any OS with pwsh. Mirrors scripts/runner-setup.sh. +# Invoke as: powershell -ExecutionPolicy Bypass -File .\scripts\runner-setup.ps1 [-DryRun] +# (the default Restricted policy refuses to run any script file). +# +# Environment (all optional except RUNNER_TOKEN on first run): RUNNER_TOKEN, RUNNER_URL, +# RUNNER_NAME, RUNNER_LABELS, FIZZY_BUILD_SLOTS, FIZZY_BUILD_MAX_LOAD, ZIG_VERSION, ZIG_SHA256, +# JJ_VERSION, RUNNER_VERSION, DEPS_CACHE_URL, DEPS_CACHE_SHA256, ZSTD_VERSION, ZSTD_SHA256, FIZZY_RUNNER_ALLOW_UNCHECKED, +# FIZZY_RUNNER_DEPS_PARTIAL_OK, FIZZY_RUNNER_NO_DEFENDER_EXCLUSION. +# +# Windows PowerShell 5.1 pitfall: with $ErrorActionPreference = 'Stop', a native command that +# writes anything to stderr (even `2>$null`) raises a terminating NativeCommandError. Every native +# call therefore goes through Native, which runs it under 'Continue'; callers check $nativeExit. +# CI exercises this file only under pwsh 7, which has no such behaviour, so keep that pattern. +[CmdletBinding()] +param([switch]$DryRun) +Set-StrictMode -Version 2 +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} + +# Registration credentials must not enter traces, command arguments, or generated files: +# take the token out of the environment now; it goes back only into the one child that needs it. +$registrationToken = $env:RUNNER_TOKEN +Remove-Item Env:\RUNNER_TOKEN -ErrorAction SilentlyContinue + +function Get-Setting($name, $default) { + $v = [Environment]::GetEnvironmentVariable($name) + if ([string]::IsNullOrEmpty($v)) { return $default } + return $v +} +function Fail($msg) { [Console]::Error.WriteLine("ERROR: $msg"); exit 1 } +$nativeExit = 0 +function Native([scriptblock]$cmd) { + $old = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { $out = & $cmd; $code = $LASTEXITCODE } finally { $ErrorActionPreference = $old } + $script:nativeExit = $code + return $out +} + +$worker = 'fizzy-worker' +$root = 'C:\fizzy' +$runner = "$root\actions-runner" +$workspaces = "$root\workspaces" +$tools = "$root\tools" +$bin = "$root\bin" +$zigCache = "$root\zig-cache" +$sourceDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$runnerUrl = Get-Setting 'RUNNER_URL' 'https://github.com/fizzyedit/work' +$buildSlots = Get-Setting 'FIZZY_BUILD_SLOTS' '2' +$buildMaxLoad = Get-Setting 'FIZZY_BUILD_MAX_LOAD' (Get-Setting 'NUMBER_OF_PROCESSORS' '4') +$runnerName = Get-Setting 'RUNNER_NAME' ([Environment]::MachineName) +$labels = Get-Setting 'RUNNER_LABELS' 'fizzy' + +# Read the cloud bootstrap's default so the entry points cannot drift. +$zigVersion = Get-Setting 'ZIG_VERSION' '' +if (-not $zigVersion) { + $m = Select-String -Path (Join-Path $sourceDir 'cloud-setup.sh') -Pattern '^zig_version="\$\{ZIG_VERSION:-([^}]*)\}"' + if ($m) { $zigVersion = $m.Matches[0].Groups[1].Value } +} +if (-not $zigVersion) { Fail 'Cannot read cloud-setup.sh Zig version' } + +function Step($text, [scriptblock]$action) { + Write-Host "+ $text" + if (-not $DryRun) { & $action } +} +function Invoke-GitHubApi($url) { + try { return Invoke-RestMethod -UseBasicParsing -Uri $url -Headers @{ 'User-Agent' = 'fizzy-runner-setup' } } + catch { + $status = 0 + try { $status = [int]$_.Exception.Response.StatusCode } catch {} + if ($status -eq 403 -or $status -eq 429) { + Fail "GitHub refused $url (HTTP $status): the anonymous API rate limit is 60 requests an hour per address. Wait and rerun, or pin RUNNER_VERSION and JJ_VERSION to skip lookups." + } + Fail "GitHub API request failed: $url ($_)" + } +} +function Get-Latest($repo) { + return (Invoke-GitHubApi "https://api.github.com/repos/$repo/releases/latest").tag_name.TrimStart('v') +} +# The sha256 GitHub records for a release asset, or $null where it publishes none. +function Get-AssetDigest($release, $name) { + foreach ($a in $release.assets) { + if ($a.name -eq $name -and $a.PSObject.Properties['digest'] -and "$($a.digest)" -match '^sha256:([0-9a-fA-F]{64})$') { + return $Matches[1] + } + } + return $null +} +# $required: refuse a download with no digest (anything an administrator then executes or unpacks). +function Get-Verified($url, $dest, $sha, $what, [bool]$required = $false) { + if ($required -and -not $sha) { Fail "$what has no published sha256 and is run as administrator; not downloading $url unverified." } + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $dest + if ($sha) { + $actual = (Get-FileHash -Algorithm SHA256 -Path $dest).Hash.ToLower() + if ($actual -ne $sha.ToLower()) { Remove-Item $dest -Force; Fail "$what sha256 mismatch: expected $sha, got $actual ($url)" } + Write-Host " $what sha256 verified" + } else { + Write-Warning "$what has no published digest; downloaded from $url without verification" + } +} +function Unzip($zip, $dest) { + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Compression.ZipFile]::ExtractToDirectory($zip, $dest) +} +function Grant($path, $rights) { + $null = Native { & icacls $path /grant $rights /C /Q 2>&1 } + if ($script:nativeExit -ne 0) { Fail "icacls /grant $rights on $path failed ($script:nativeExit)" } +} + +# The runner executes whatever the repository's workflows say, so it may only serve a private repo. +if ((Get-Setting 'FIZZY_RUNNER_ALLOW_UNCHECKED' '') -eq '1') { + Write-Host "+ skip the private-repo check for $runnerUrl (FIZZY_RUNNER_ALLOW_UNCHECKED=1)" +} else { + if ($runnerUrl -notmatch '^https://github\.com/([^/]+/[^/]+)/?$') { + Fail "RUNNER_URL must be https://github.com//: $runnerUrl" + } + $repoPath = $Matches[1] + $verdict = $null + if (Get-Command gh -ErrorAction SilentlyContinue) { + $null = Native { & gh auth status 2>$null } + if ($nativeExit -eq 0) { + $out = Native { & gh api "repos/$repoPath" --jq .private 2>$null } + if ($nativeExit -eq 0) { $verdict = "$out".Trim() } + } + } + if ($verdict -eq 'true') { + Write-Host "+ confirmed $repoPath is private" + } elseif ($verdict) { + Fail "Refusing $runnerUrl`: not confirmed private. Never register a runner on a public repo." + } elseif ($DryRun) { + Write-Host "+ (dry run) cannot verify $repoPath is private: gh is missing or not logged in; a real run would refuse" + } else { + [Console]::Error.WriteLine('Cannot verify the repository is private: gh is missing, not logged in, or cannot read it.') + Fail 'Log in with gh (gh auth login), or set FIZZY_RUNNER_ALLOW_UNCHECKED=1 after checking by hand.' + } +} + +Write-Host '+ verify Windows 11, elevation, architecture, Git for Windows, and no active builds or Actions jobs' +$arch = [string][Runtime.InteropServices.RuntimeInformation]::OSArchitecture +switch ($arch) { + 'X64' { $zigArch = 'x86_64'; $jjArch = 'x86_64'; $runnerArch = 'x64' } + 'Arm64' { $zigArch = 'aarch64'; $jjArch = 'aarch64'; $runnerArch = 'arm64' } + default { Fail 'Only x64 and arm64 are supported.' } +} +$gitRoot = 'C:\Program Files\Git' +if (-not $DryRun) { + if ($env:OS -ne 'Windows_NT') { Fail 'Run on Windows 11.' } + $me = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + if (-not $me.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Fail 'Run from an elevated (Administrator) PowerShell.' + } + if ([Environment]::OSVersion.Version.Build -lt 22000) { Fail 'Windows 11 (build 22000) or newer is required.' } + $git = Get-Command git.exe -ErrorAction SilentlyContinue + if (-not $git) { Fail 'Install Git for Windows first (winget install Git.Git); the runner and scripts/zb need it.' } + # git.exe is \cmd\git.exe (or \bin\git.exe); the root holds Git's bash. + $gitRoot = Split-Path -Parent (Split-Path -Parent $git.Source) + if (-not (Test-Path "$gitRoot\bin\bash.exe")) { Fail "Git for Windows' bash.exe not found at $gitRoot\bin; reinstall Git for Windows." } + if (Get-Process -Name zig, zls, Runner.Worker -ErrorAction SilentlyContinue) { + Fail 'Stop active builds and Actions jobs before provisioning.' + } +} + +# The runner's state: .runner is written by config.cmd, .service by the service install. +$configured = (Test-Path "$runner\.runner") -and (Test-Path "$runner\.service") +$partial = (Test-Path "$runner\.runner") -and -not (Test-Path "$runner\.service") +if (-not $DryRun -and -not $configured -and -not $registrationToken) { + Fail 'Set RUNNER_TOKEN to a fresh GitHub runner registration token.' +} + +# Pause the listener during a rerun so a queued job cannot start while tools are replaced. +if ($configured) { + $serviceName = (Get-Content "$runner\.service" -Raw).Trim() + Step "stop service $serviceName" { Stop-Service -Name $serviceName -ErrorAction SilentlyContinue } +} + +# --- the dedicated, unprivileged account ------------------------------------------------------ +$password = $null +function New-Password { + $junk = New-Object byte[] 24 + (New-Object Security.Cryptography.RNGCryptoServiceProvider).GetBytes($junk) + return [Convert]::ToBase64String($junk) + 'aA1!' +} +Step "create local user $worker (not an administrator) if missing" { + if (-not (Get-LocalUser -Name $worker -ErrorAction SilentlyContinue)) { + $script:password = New-Password + New-LocalUser -Name $worker -Password (ConvertTo-SecureString $script:password -AsPlainText -Force) ` + -FullName 'Fizzy Actions worker' -PasswordNeverExpires -UserCannotChangePassword -AccountNeverExpires | Out-Null + } + # Refuse a privileged existing account rather than silently repurposing it. `net localgroup` + # lists members as text, where Get-LocalGroupMember throws on an orphaned SID in 5.1. + $adminSid = New-Object Security.Principal.SecurityIdentifier 'S-1-5-32-544' + $adminGroup = $adminSid.Translate([Security.Principal.NTAccount]).Value.Split('\')[-1] + $members = Native { & net localgroup $adminGroup 2>$null } + if ($nativeExit -ne 0) { Fail "Cannot list the $adminGroup group (net localgroup exit $nativeExit); not creating or reusing $worker unchecked." } + if ($members | Where-Object { $_.Trim() -ieq $worker -or $_.Trim() -like "*\$worker" }) { + Fail "$worker must be an unprivileged dedicated account" + } +} +# The scheduled cleanup runs as the worker without a stored password (S4U), which needs this right. +Step "grant $worker the right to log on as a batch job" { + $sid = (Get-LocalUser -Name $worker).SID.Value + $cfg = Join-Path ([IO.Path]::GetTempPath()) ('fizzy-rights-' + [Guid]::NewGuid().ToString('N') + '.inf') + try { + $null = Native { & secedit /export /cfg $cfg /areas USER_RIGHTS /quiet 2>&1 } + if ($nativeExit -ne 0) { Fail "secedit export failed ($nativeExit)" } + $lines = @(Get-Content $cfg) + $found = $false + $lines = @($lines | ForEach-Object { + if ($_ -match '^SeBatchLogonRight\s*=') { + $found = $true + if ($_ -notlike "*$sid*") { $_ + ",*$sid" } else { $_ } + } else { $_ } + }) + if (-not $found) { $lines = @($lines | ForEach-Object { $_; if ($_ -match '^\[Privilege Rights\]') { "SeBatchLogonRight = *$sid" } }) } + Set-Content -Path $cfg -Value $lines -Encoding Unicode + $db = "$cfg.sdb" + $null = Native { & secedit /configure /db $db /cfg $cfg /areas USER_RIGHTS /quiet 2>&1 } + if ($nativeExit -ne 0) { Fail "secedit configure failed ($nativeExit)" } + $null = Native { & secedit /export /cfg $cfg /areas USER_RIGHTS /quiet 2>&1 } + if (-not ((Get-Content $cfg | Where-Object { $_ -match '^SeBatchLogonRight' }) -like "*$sid*")) { + Fail "$worker did not get SeBatchLogonRight" + } + } finally { Remove-Item "$cfg*" -Force -ErrorAction SilentlyContinue } +} + +# --- the directory layout and its access rules ------------------------------------------------ +# C:\ gives Users Modify by inheritance, so the root drops inheritance first, before anything +# is created in it. Only SYSTEM and Administrators write the tools (they are on the machine PATH) +# and bin; the worker gets Modify on exactly the directories jobs write to. +Step "create $root with only SYSTEM and Administrators, then grant $worker per directory" { + New-Item -ItemType Directory -Force -Path $root | Out-Null + $null = Native { & icacls $root /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' /C /Q 2>&1 } + if ($nativeExit -ne 0) { Fail "icacls on $root failed ($nativeExit)" } + foreach ($d in $runner, $workspaces, $tools, "$tools\bin", $bin, $zigCache) { + New-Item -ItemType Directory -Force -Path $d | Out-Null + } + foreach ($d in $runner, $workspaces, $zigCache) { Grant $d "${worker}:(OI)(CI)M" } + foreach ($d in $tools, $bin) { Grant $d "${worker}:(OI)(CI)RX" } +} + +# --- Zig's dependency cache holds long paths and links ------------------------------------------ +Step 'enable long paths (registry LongPathsEnabled, git core.longpaths)' { + Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name LongPathsEnabled -Value 1 -Type DWord + $null = Native { & git config --system core.longpaths true 2>&1 } + if ($nativeExit -ne 0) { Fail "git config --system core.longpaths failed ($nativeExit)" } +} +if ((Get-Setting 'FIZZY_RUNNER_NO_DEFENDER_EXCLUSION' '') -ne '1') { + Step "exclude $root from Microsoft Defender real-time scanning (optional; FIZZY_RUNNER_NO_DEFENDER_EXCLUSION=1 skips)" { + try { Add-MpPreference -ExclusionPath $root } + catch { Write-Warning "Defender exclusion not added ($_); builds work without it, only slower" } + } +} + +# --- Zig, jj, zstd, all hash-checked where a digest is published --------------------------------- +# Short, admin-only path: archives unpack deep trees and powershell.exe has the 260-character limit. +$scratch = "$root\tmp\DRY-RUN" +if (-not $DryRun) { + $scratch = "$root\tmp\" + [Guid]::NewGuid().ToString('N').Substring(0, 8) + New-Item -ItemType Directory -Path $scratch | Out-Null +} +$jjVersion = Get-Setting 'JJ_VERSION' 'latest' +$runnerVersion = Get-Setting 'RUNNER_VERSION' 'latest' +$zstdVersion = $null +try { + $haveZig = $false + if (Test-Path "$tools\zig\zig.exe") { + $haveZig = ("$(Native { & "$tools\zig\zig.exe" version 2>$null })".Trim() -eq $zigVersion) + } + if ($haveZig) { Write-Host "+ zig $zigVersion already installed" } else { + Step "download zig $zigVersion for $zigArch-windows (sha256 from ziglang.org/download/index.json) to $tools\zig" { + $url = "https://ziglang.org/download/$zigVersion/zig-$zigArch-windows-$zigVersion.zip" + $sha = Get-Setting 'ZIG_SHA256' '' + # The index is large; Windows PowerShell 5.1's ConvertFrom-Json can hit its size limit, + # so fall back to a serializer with no limit. + $raw = (Invoke-WebRequest -UseBasicParsing -Uri 'https://ziglang.org/download/index.json').Content + $tarball = $null; $shasum = $null + try { + $r = ($raw | ConvertFrom-Json).PSObject.Properties[$zigVersion] + if ($r -and $r.Value.PSObject.Properties["$zigArch-windows"]) { + $e = $r.Value.PSObject.Properties["$zigArch-windows"].Value + $tarball = $e.tarball; $shasum = $e.shasum + } + } catch { + Add-Type -AssemblyName System.Web.Extensions + $ser = New-Object System.Web.Script.Serialization.JavaScriptSerializer + $ser.MaxJsonLength = [int]::MaxValue + $d = $ser.DeserializeObject($raw) + if ($d.ContainsKey($zigVersion) -and $d[$zigVersion].ContainsKey("$zigArch-windows")) { + $tarball = $d[$zigVersion]["$zigArch-windows"]['tarball']; $shasum = $d[$zigVersion]["$zigArch-windows"]['shasum'] + } + } + if ($tarball) { $url = $tarball } + if (-not $sha) { $sha = $shasum } + if (-not $sha) { Fail "No sha256 for zig $zigVersion in ziglang.org's index.json; set ZIG_SHA256 to the expected value." } + Get-Verified $url "$scratch\zig.zip" $sha 'zig' + Unzip "$scratch\zig.zip" "$scratch\zig" + $inner = Get-ChildItem "$scratch\zig" -Directory | Select-Object -First 1 + if (Test-Path "$tools\zig") { Remove-Item -Recurse -Force "$tools\zig" } + Move-Item $inner.FullName "$tools\zig" + } + } + + if ($jjVersion -eq 'latest') { + Step 'resolve latest jj-vcs/jj release via GitHub API' { $script:jjVersion = Get-Latest 'jj-vcs/jj' } + } + Step "download jj and install to $tools\bin" { + $asset = "jj-v$jjVersion-$jjArch-pc-windows-msvc.zip" + $rel = Invoke-GitHubApi "https://api.github.com/repos/jj-vcs/jj/releases/tags/v$jjVersion" + Get-Verified "https://github.com/jj-vcs/jj/releases/download/v$jjVersion/$asset" "$scratch\jj.zip" (Get-AssetDigest $rel $asset) 'jj' + Unzip "$scratch\jj.zip" "$scratch\jj" + Copy-Item "$scratch\jj\jj.exe" "$tools\bin\jj.exe" -Force + } + # tools\zig and tools\bin are admin-only, so they are safe on the machine PATH (for the person's + # shell). The type of the value (REG_EXPAND_SZ) is kept, and the entries appended, not prepended. + Step "append $tools\zig and $tools\bin to the machine PATH" { + $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment', $true) + try { + $path = [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + $kind = $key.GetValueKind('Path') + foreach ($d in "$tools\zig", "$tools\bin") { + if (-not ($path.Split(';') | Where-Object { $_.TrimEnd('\') -ieq $d })) { $path = $path.TrimEnd(';') + ';' + $d } + } + $key.SetValue('Path', $path, $kind) + } finally { $key.Close() } + } + + # --- the Actions runner (never unpacked over a configured one: its updater owns it) ---------- + if (-not $configured) { + if ($runnerVersion -eq 'latest') { + Step 'resolve latest actions/runner release via GitHub API' { $script:runnerVersion = Get-Latest 'actions/runner' } + } + Step "download actions runner, verify its digest, and unpack to $runner" { + $asset = "actions-runner-win-$runnerArch-$runnerVersion.zip" + $rel = Invoke-GitHubApi "https://api.github.com/repos/actions/runner/releases/tags/v$runnerVersion" + $sha = Get-AssetDigest $rel $asset + if (-not $sha -and "$($rel.body)" -match "([0-9a-fA-F]{64})") { $sha = $Matches[1] } + if (-not $sha) { Fail "No published sha256 for $asset; the runner is not installed unverified." } + Get-Verified "https://github.com/actions/runner/releases/download/v$runnerVersion/$asset" "$scratch\runner.zip" $sha 'actions runner' + # A partial earlier attempt may have left files; start from a clean runner directory. + Get-ChildItem $runner -Force | Remove-Item -Recurse -Force + Unzip "$scratch\runner.zip" $runner + } + } + + # --- dependency cache into the Zig global cache -------------------------------------------- + $depsUrl = Get-Setting 'DEPS_CACHE_URL' 'https://github.com/fizzyedit/fizzy/releases/download/deps-cache/zig-deps-linux.tar.zst' + Step "restore the deps cache from $depsUrl into $zigCache" { + # zstd publishes no digest, so its version and sha256 are pinned here (override both together). + $zstdVersion = Get-Setting 'ZSTD_VERSION' '1.5.7' + $zstdSha = Get-Setting 'ZSTD_SHA256' 'acb4e8111511749dc7a3ebedca9b04190e37a17afeb73f55d4425dbf0b90fad9' + if ($zstdVersion -ne '1.5.7' -and -not (Get-Setting 'ZSTD_SHA256' '')) { Fail 'ZSTD_VERSION other than 1.5.7 needs ZSTD_SHA256.' } + Get-Verified "https://github.com/facebook/zstd/releases/download/v$zstdVersion/zstd-v$zstdVersion-win64.zip" "$scratch\zstd.zip" $zstdSha 'zstd' $true + Unzip "$scratch\zstd.zip" "$scratch\zstd" + $zstd = Get-ChildItem "$scratch\zstd" -Recurse -Filter zstd.exe | Select-Object -First 1 + $dsha = Get-Setting 'DEPS_CACHE_SHA256' '' + if (-not $dsha -and $depsUrl -match '^https://github\.com/fizzyedit/fizzy/releases/download/([^/]+)/([^/]+)$') { + $dsha = Get-AssetDigest (Invoke-GitHubApi "https://api.github.com/repos/fizzyedit/fizzy/releases/tags/$($Matches[1])") $Matches[2] + } + # A cache from anywhere else is a different trust root: it needs a digest you supply. + $depsDefault = ($depsUrl -like 'https://github.com/fizzyedit/fizzy/releases/download/deps-cache/*') + Get-Verified $depsUrl "$scratch\deps.tar.zst" $dsha 'deps cache (set DEPS_CACHE_SHA256 for a non-default DEPS_CACHE_URL)' (-not $depsDefault) + $null = Native { & $zstd.FullName -d -q "$scratch\deps.tar.zst" -o "$scratch\deps.tar" 2>&1 } + if ($nativeExit -ne 0) { Fail "zstd failed ($nativeExit)" } + $log = Native { & "$env:SystemRoot\System32\tar.exe" -xf "$scratch\deps.tar" -C $zigCache 2>&1 } + $tarExit = $nativeExit + $count = @(Get-ChildItem "$zigCache\p" -Directory -ErrorAction SilentlyContinue).Count + if ($tarExit -ne 0 -or $count -eq 0) { + $log | Select-Object -First 10 | ForEach-Object { [Console]::Error.WriteLine("tar: $_") } + if ($count -gt 0 -and (Get-Setting 'FIZZY_RUNNER_DEPS_PARTIAL_OK' '') -eq '1') { + Write-Warning "PARTIAL RESTORE: $count packages in $zigCache\p, tar exited $tarExit; zig will fetch the rest" + } else { + Fail "Restoring the deps cache failed (tar exit $tarExit, $count packages). Set FIZZY_RUNNER_DEPS_PARTIAL_OK=1 to accept a partial restore." + } + } else { Write-Host " restored $count packages" } + } + + # The runner passes .env to every job, so scripts/zb inherits the slot count and load ceiling. + # A service reads the machine environment only when the SCM starts, so the job PATH is set here + # too, with Git for Windows' bash ahead of System32's WSL launcher bash.exe. + Step "write build limits, ZIG_GLOBAL_CACHE_DIR and PATH to $runner\.env" { + $keep = @() + if (Test-Path "$runner\.env") { + $keep = @(Get-Content "$runner\.env" | Where-Object { $_ -notmatch '^(FIZZY_BUILD_SLOTS|FIZZY_BUILD_MAX_LOAD|ZIG_GLOBAL_CACHE_DIR|PATH)=' }) + } + $jobPath = "$gitRoot\cmd;$gitRoot\bin;$tools\zig;$tools\bin;" + [Environment]::GetEnvironmentVariable('Path', 'Machine') + $lines = $keep + @("FIZZY_BUILD_SLOTS=$buildSlots", "FIZZY_BUILD_MAX_LOAD=$buildMaxLoad", "ZIG_GLOBAL_CACHE_DIR=$zigCache", "PATH=$jobPath") + [IO.File]::WriteAllText("$runner\.env", (($lines -join "`r`n") + "`r`n"), (New-Object Text.UTF8Encoding $false)) + } + + # --- register and install the service, as the unprivileged account -------------------------- + if (-not $configured) { + if ($partial) { Write-Host '+ an earlier run left .runner without the service: clear the local registration and register again with --replace' } + Write-Host "+ configure runner as a Windows service running as $worker, token and password from the environment (redacted)" + if (-not $DryRun) { + if ($partial) { Remove-Item "$runner\.runner", "$runner\.credentials", "$runner\.credentials_rsaparams" -Force -ErrorAction SilentlyContinue } + if (-not $password) { + # The account pre-existed but the runner is new: set a fresh password only we know. + $password = New-Password + Set-LocalUser -Name $worker -Password (ConvertTo-SecureString $password -AsPlainText -Force) + } + $env:ACTIONS_RUNNER_INPUT_TOKEN = $registrationToken + $env:ACTIONS_RUNNER_INPUT_WINDOWSLOGONPASSWORD = $password + try { + Push-Location $runner + $null = Native { & .\config.cmd --unattended --replace --url $runnerUrl --name $runnerName --labels $labels --work _work ` + --runasservice --windowslogonaccount "$([Environment]::MachineName)\$worker" 2>&1 | ForEach-Object { Write-Host "$_" } } + if ($nativeExit -ne 0) { Fail "config.cmd failed ($nativeExit); rerun with a fresh token" } + } finally { + Pop-Location + Remove-Item Env:\ACTIONS_RUNNER_INPUT_TOKEN, Env:\ACTIONS_RUNNER_INPUT_WINDOWSLOGONPASSWORD -ErrorAction SilentlyContinue + $registrationToken = $null; $password = $null + } + if (-not (Test-Path "$runner\.service")) { Fail 'config.cmd succeeded but installed no service; rerun to register again.' } + } + } +} finally { + if (-not $DryRun -and (Test-Path $scratch)) { Remove-Item -Recurse -Force $scratch } +} + +Step 'start the runner service' { + Start-Service -Name (Get-Content "$runner\.service" -Raw).Trim() +} + +# --- daily cleanup ---------------------------------------------------------------------------- +Step "install runner-clean.ps1 to $bin and register the daily task FizzyRunnerClean (as $worker, S4U: no stored password)" { + Copy-Item (Join-Path $sourceDir 'runner-clean.ps1') "$bin\runner-clean.ps1" -Force + $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$bin\runner-clean.ps1`"" + $trigger = New-ScheduledTaskTrigger -Daily -At 4am -RandomDelay (New-TimeSpan -Minutes 30) + $principal = New-ScheduledTaskPrincipal -UserId "$([Environment]::MachineName)\$worker" -LogonType S4U -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 2) -MultipleInstances IgnoreNew + Register-ScheduledTask -TaskName FizzyRunnerClean -Action $action -Trigger $trigger -Principal $principal ` + -Settings $settings -Description 'Daily Fizzy worker disk hygiene' -Force | Out-Null +} +Step 'run the task once and check its result (0x80070569 means the batch-logon right is missing)' { + Start-ScheduledTask -TaskName FizzyRunnerClean + $deadline = (Get-Date).AddMinutes(3) + do { Start-Sleep -Seconds 3 } while ((Get-ScheduledTask -TaskName FizzyRunnerClean).State -eq 'Running' -and (Get-Date) -lt $deadline) + $result = (Get-ScheduledTaskInfo -TaskName FizzyRunnerClean).LastTaskResult + if ($result -ne 0) { Fail ("FizzyRunnerClean last result 0x{0:X}; the runner is up but the daily cleanup does not run." -f $result) } + Write-Host ' FizzyRunnerClean ran and returned 0' +} +if ($DryRun) { Write-Host 'Dry run complete; no changes made.' } else { + Write-Host 'Worker ready. Verify per docs/AGENT_WORK.md, "Self-hosted runner (Windows)".' +} diff --git a/scripts/test-runner-setup-windows.sh b/scripts/test-runner-setup-windows.sh new file mode 100755 index 000000000..c3eb543b8 --- /dev/null +++ b/scripts/test-runner-setup-windows.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Runs scripts/test-runner-setup.ps1 (the runner-setup.ps1 dry run) under pwsh; skips cleanly +# where pwsh is not installed. +cd "$(dirname "$0")" || exit 1 +if ! command -v pwsh >/dev/null 2>&1; then + echo 'skip: pwsh not installed' + exit 0 +fi +exec pwsh -NoProfile -ExecutionPolicy Bypass -File ./test-runner-setup.ps1 diff --git a/scripts/test-runner-setup.ps1 b/scripts/test-runner-setup.ps1 new file mode 100644 index 000000000..cd0b3b929 --- /dev/null +++ b/scripts/test-runner-setup.ps1 @@ -0,0 +1,60 @@ +# Checks the pure parts of scripts/runner-setup.ps1 through -DryRun, as test-runner-setup.sh does +# for the Ubuntu script. Needs pwsh and a POSIX or Windows host; skips cleanly without pwsh. +$pwsh = Get-Command pwsh -ErrorAction SilentlyContinue +if (-not $pwsh) { Write-Host 'skip: pwsh not installed'; exit 0 } +$dir = Split-Path -Parent $MyInvocation.MyCommand.Path +$tmp = Join-Path ([IO.Path]::GetTempPath()) ('runner-test-' + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory $tmp | Out-Null +function Fail($m) { Write-Host "FAIL: $m"; Remove-Item -Recurse -Force $tmp; exit 1 } +# Fake gh on PATH: `gh auth` succeeds, `gh api` prints the contents of the private file. +if ($IsWindows -or $env:OS -eq 'Windows_NT') { + Set-Content "$tmp\gh.cmd" "@echo off`r`nif `"%1`"==`"auth`" exit /b 0`r`necho %2> `"$tmp\asked`"`r`ntype `"$tmp\private`"" +} else { + Set-Content "$tmp/gh" "#!/bin/sh`n[ `"`$1`" = auth ] && exit 0`necho `"`$2`" >'$tmp/asked'`ncat '$tmp/private'" + chmod +x "$tmp/gh" +} +$sep = [IO.Path]::PathSeparator +function Setup($extraEnv) { + $env:PATH = "$tmp$sep$script:origPath" + foreach ($k in $extraEnv.Keys) { Set-Item "Env:$k" $extraEnv[$k] } + $out = & $pwsh.Source -NoProfile -ExecutionPolicy Bypass -File "$dir/runner-setup.ps1" -DryRun 2>&1 | Out-String + $code = $LASTEXITCODE + foreach ($k in $extraEnv.Keys) { Remove-Item "Env:$k" -ErrorAction SilentlyContinue } + return @{ Out = $out; Code = $code } +} +$origPath = $env:PATH + +Set-Content "$tmp/private" 'true' +$r = Setup @{} +if ($r.Code -ne 0) { Fail "private default refused: $($r.Out)" } +if ((Get-Content "$tmp/asked").Trim() -ne 'repos/fizzyedit/work') { Fail 'default is not fizzyedit/work' } +if ($r.Out -notmatch 'Dry run complete') { Fail "no completion line: $($r.Out)" } + +Set-Content "$tmp/private" 'false' +$r = Setup @{} +if ($r.Code -eq 0) { Fail 'public repo accepted' } +if ($r.Out -notmatch 'Refusing') { Fail "no refusal line: $($r.Out)" } + +Set-Content "$tmp/private" 'true' +$r = Setup @{ RUNNER_URL = 'https://github.com/fizzyedit' } +if ($r.Code -eq 0) { Fail 'org URL accepted' } + +# Token and password never appear in the dry-run output. +$r = Setup @{ RUNNER_TOKEN = 'SECRET-TOKEN-VALUE' } +if ($r.Out -match 'SECRET-TOKEN-VALUE') { Fail 'token echoed' } + +# A failing gh stub is an unverifiable repo: a dry run says so and goes on (it must not need a gh +# login); a real run refuses (not testable off Windows). The override is reported. +if ($IsWindows -or $env:OS -eq 'Windows_NT') { Set-Content "$tmp\gh.cmd" "@exit /b 1" } else { Set-Content "$tmp/gh" "#!/bin/sh`nexit 1" } +$r = Setup @{} +if ($r.Code -ne 0) { Fail "dry run needs a gh login: $($r.Out)" } +if ($r.Out -notmatch 'a real run would refuse') { Fail "unverifiable repo not reported: $($r.Out)" } +$r = Setup @{ FIZZY_RUNNER_ALLOW_UNCHECKED = '1' } +if ($r.Code -ne 0) { Fail "override refused: $($r.Out)" } +if ($r.Out -notmatch 'skip the private-repo check') { Fail 'override not reported' } + +$env:PATH = $origPath +$out = & $pwsh.Source -NoProfile -ExecutionPolicy Bypass -File "$dir/runner-clean.ps1" -DryRun 2>&1 | Out-String +if ($LASTEXITCODE -ne 0) { Fail "runner-clean dry run failed: $out" } +Remove-Item -Recurse -Force $tmp +Write-Host 'ok'