Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }})
Expand Down
88 changes: 87 additions & 1 deletion docs/AGENT_WORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
158 changes: 158 additions & 0 deletions scripts/runner-clean.ps1
Original file line number Diff line number Diff line change
@@ -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() }
Loading
Loading