Skip to content
Draft
19 changes: 19 additions & 0 deletions .github/actions/windows_msys2_prep/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: 'windows msys2 prep'
description: >
Adds C:\msys64\usr\bin to PATH and installs the MSYS2 packages required by
hack/test-templates.sh. The PATH prepend persists into subsequent job steps
via $GITHUB_PATH, so the test step can call cygpath / bash / pacman tools
directly without re-prepending.
runs:
using: composite
steps:
- name: Prepend MSYS2 to PATH and install test dependencies
shell: pwsh
# The value written to $GITHUB_PATH is the static literal C:\msys64\usr\bin,
# which is the canonical pattern for adding a directory to subsequent
# steps' PATH. No PR-controlled data reaches the env file.
run: | # zizmor: ignore[github-env]
$ErrorActionPreference = 'Stop'
Add-Content -Path $env:GITHUB_PATH -Value 'C:\msys64\usr\bin'
& 'C:\msys64\usr\bin\pacman.exe' -Sy --noconfirm openbsd-netcat diffutils socat w3m
if ($LASTEXITCODE -ne 0) { throw "pacman failed: $LASTEXITCODE" }
22 changes: 22 additions & 0 deletions .github/actions/windows_plain_build/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: 'windows plain build'
description: >
Builds limactl.exe and the Linux/amd64 guest agent with `go build` on a
vanilla Windows host (no MSYS2 make / bash). The guest agent must be built
separately because limactl looks it up at
_output/share/lima/lima-guestagent.Linux-x86_64 when starting an instance,
and `go build ./cmd/limactl` does not produce that file.
runs:
using: composite
steps:
- name: Build limactl and Linux/amd64 guest agent
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
go build -o _output\bin\limactl.exe .\cmd\limactl
if ($LASTEXITCODE -ne 0) { throw "limactl build failed: $LASTEXITCODE" }
New-Item -ItemType Directory -Force -Path _output\share\lima | Out-Null
$env:CGO_ENABLED = '0'
$env:GOOS = 'linux'
$env:GOARCH = 'amd64'
go build -o _output\share\lima\lima-guestagent.Linux-x86_64 .\cmd\lima-guestagent
if ($LASTEXITCODE -ne 0) { throw "lima-guestagent build failed: $LASTEXITCODE" }
173 changes: 173 additions & 0 deletions .github/actions/windows_plain_host/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
name: 'windows plain host'
description: >
Uninstalls MSYS2 and Git for Windows from the windows-2025 runner so the
job exercises Lima against the toolchain a vanilla Windows 10/11 install
would have: native OpenSSH from %SystemRoot%\System32\OpenSSH, wsl.exe, and
tar. Then verifies the toolchain is actually absent across four layers
(filesystem, PATH, smoking-gun binaries, registry uninstall keys) so a
future runner-image change that reinstalls these via a new path fails the
job loudly instead of silently masking a regression.

Must run AFTER any step that needs git CLI (actions/checkout uses git when
available; windows_plain_templates shells `git ls-tree`) and BEFORE the
build / smoke test steps.
runs:
using: composite
steps:
- name: Uninstall Git for Windows
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$uninstaller = 'C:\Program Files\Git\unins000.exe'
if (Test-Path $uninstaller) {
Write-Host "Running Git for Windows Inno Setup uninstaller..."
& $uninstaller /VERYSILENT /NORESTART /SUPPRESSMSGBOXES
# The uninstaller forks and the parent exits immediately; wait for
# the worker process to finish before we wipe the leftover dir.
Wait-Process -Name unins000 -Timeout 120 -ErrorAction SilentlyContinue
} else {
Write-Host "No Git for Windows uninstaller at $uninstaller; skipping."
}
foreach ($d in 'C:\Program Files\Git', 'C:\Program Files (x86)\Git') {
if (Test-Path $d) {
Write-Host "Removing leftover $d"
Remove-Item $d -Recurse -Force -ErrorAction SilentlyContinue
}
}

- name: Uninstall MSYS2 and standalone mingw
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# MSYS2 on the runner-images Windows-2025 image is an extracted tree,
# not a winget / MSI package. The supported "uninstall" is to kill any
# process still mapping msys-2.0.dll (same pattern as Install-Msys2.ps1
# uses between pacman runs) and remove the directory. C:\mingw64 and
# C:\mingw32 are standalone GCC toolchain trees shipped on the same
# runner image — also extracted, also removed by directory wipe.
$procs = Get-Process | Where-Object {
try { $_.Modules.ModuleName -contains 'msys-2.0.dll' } catch { $false }
}
if ($procs) {
Write-Host "Stopping $($procs.Count) process(es) still mapping msys-2.0.dll"
$procs | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
}
foreach ($d in 'C:\msys64', 'C:\tools\msys64', 'C:\msys32',
'C:\mingw64', 'C:\mingw32') {
if (Test-Path $d) {
Write-Host "Removing $d"
Remove-Item $d -Recurse -Force -ErrorAction SilentlyContinue
}
}

- name: Scrub forbidden entries from Machine + User PATH
shell: pwsh
# The PATH= we publish here is the runner's own Machine + User PATH
# with a static deny-list of toolchain prefixes removed — no
# PR-controlled data reaches the env file.
run: | # zizmor: ignore[github-env]
# Even after the uninstall steps removed the toolchain directories,
# the Machine and User PATH variables still contain stale entries
# (e.g. C:\mingw64\bin remains registered on the runner image after
# only the bin directory is deleted). Rewrite both scopes so the
# verify step's Layer 2 check sees a clean host, then publish the
# composed PATH via $GITHUB_ENV so subsequent job steps inherit
# the scrubbed value — Machine-scope changes via the registry do
# not propagate to the long-running runner process. Steps that
# need to add more entries (e.g. the QEMU install dir) must write
# PATH= through $GITHUB_ENV too; $GITHUB_PATH prepending is
# suppressed once $GITHUB_ENV defines PATH for the workflow.
$forbidden = '(?i)(msys|mingw|cygwin|\\Git\\(cmd|bin|usr))'
foreach ($scope in @('Machine','User')) {
$orig = [Environment]::GetEnvironmentVariable('Path', $scope)
if (-not $orig) { continue }
$cleaned = ($orig -split ';' | Where-Object { $_ -and ($_ -notmatch $forbidden) }) -join ';'
if ($cleaned -ne $orig) {
Write-Host "Scrubbing $scope PATH"
[Environment]::SetEnvironmentVariable('Path', $cleaned, $scope)
}
}
$machine = [Environment]::GetEnvironmentVariable('Path','Machine')
$user = [Environment]::GetEnvironmentVariable('Path','User')
$combined = (@($machine, $user) | Where-Object { $_ }) -join ';'
Add-Content -Path $env:GITHUB_ENV -Value "PATH=$combined"

- name: Verify host is vanilla Windows (no MSYS2 / Git for Windows / Cygwin)
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# This step's process inherited PATH at step-start, before the
# scrub above modified the Machine + User registry values. Refresh
# the process PATH from those scrubbed values so Layer 2 reflects
# what subsequent job steps will inherit.
$machine = [Environment]::GetEnvironmentVariable('Path','Machine')
$user = [Environment]::GetEnvironmentVariable('Path','User')
$env:PATH = (@($machine, $user) | Where-Object { $_ }) -join ';'
$fail = @()

# --- Layer 1: filesystem ---
$fsForbidden = @(
'C:\msys64', 'C:\tools\msys64', 'C:\msys32',
'C:\Program Files\Git', 'C:\Program Files (x86)\Git',
'C:\mingw64', 'C:\mingw32',
'C:\cygwin', 'C:\cygwin64', 'C:\tools\cygwin'
)
foreach ($d in $fsForbidden) {
if (Test-Path $d) { $fail += "filesystem: $d still exists" }
}

# --- Layer 2: PATH (process + machine + user) ---
$pathRegex = '(?i)(msys|mingw|cygwin|\\Git\\(cmd|bin|usr))'
foreach ($scope in @('Process','Machine','User')) {
$p = [Environment]::GetEnvironmentVariable('Path', $scope)
if (-not $p) { continue }
foreach ($entry in $p -split ';') {
if ($entry -and ($entry -match $pathRegex)) {
$fail += ("PATH ({0}): {1}" -f $scope, $entry)
}
}
}

# --- Layer 3: smoking-gun binaries ---
# Any of these resolving on PATH means a toolchain is still reachable.
# bash is special: WSL ships %SystemRoot%\System32\bash.exe which is
# legitimately present on the wsl2 plain job, so allow only that path.
$forbiddenCmds = @('cygpath','pacman','mintty','git','git-bash','git-cmd')
foreach ($cmd in $forbiddenCmds) {
$g = Get-Command $cmd -ErrorAction SilentlyContinue
if ($g) { $fail += ("binary: {0} -> {1}" -f $cmd, $g.Source) }
}
$allowedBash = Join-Path $env:SystemRoot 'System32\bash.exe'
$bash = Get-Command bash -ErrorAction SilentlyContinue
if ($bash -and ($bash.Source -ne $allowedBash)) {
$fail += "binary: bash -> $($bash.Source) (only $allowedBash is allowed)"
}
$sh = Get-Command sh -ErrorAction SilentlyContinue
if ($sh) { $fail += "binary: sh -> $($sh.Source)" }

# --- Layer 4: registry uninstall keys ---
$uninstallRoots = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
)
foreach ($root in $uninstallRoots) {
if (-not (Test-Path $root)) { continue }
$keys = Get-ChildItem $root -ErrorAction SilentlyContinue
foreach ($k in $keys) {
$props = Get-ItemProperty $k.PSPath -ErrorAction SilentlyContinue
if ($props -and $props.DisplayName -and
($props.DisplayName -match '(?i)(msys|git for windows|cygwin|mingw)')) {
$fail += ("registry: {0} -> DisplayName='{1}'" -f $k.PSPath, $props.DisplayName)
}
}
}

if ($fail.Count -gt 0) {
Write-Host "Plain-Windows verification FAILED:" -ForegroundColor Red
$fail | ForEach-Object { Write-Host " $_" }
Write-Error "Toolchain remnants detected; this runner is not in plain-Windows state."
exit 1
}
Write-Host "Plain-Windows verification PASSED."
87 changes: 87 additions & 0 deletions .github/actions/windows_plain_smoke_test/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: 'windows plain smoke test'
description: >
Runs Lima's create / start / shell / copy / stop / delete cycle against a
given template on a plain Windows host. Each limactl invocation runs with
--debug so traces land in the workflow log, and on failure the workflow
dumps the instance's hostagent / serial logs so the run carries enough
context to diagnose without re-running.
inputs:
template:
description: Path to the Lima template (e.g. .\templates\experimental\wsl2.yaml).
required: true
instance-name:
description: Name to use for the test instance.
required: false
default: plain
lima-home-suffix:
description: >
Suffix appended to runner.temp for LIMA_HOME (e.g. "plain-wsl2"). Must be
unique per job sharing a runner so concurrent jobs cannot collide.
required: true
vm-type:
description: >
Lima vmType to force via `--vm-type` (e.g. "qemu" or "wsl2"). When empty,
`limactl create` auto-selects, which on Windows prefers wsl2 over qemu
even when the template ships a disk image incompatible with wsl2.
required: false
default: ''
runs:
using: composite
steps:
- name: Smoke test (create / start / shell / copy / stop / delete)
shell: pwsh
env:
LIMA_HOME: ${{ runner.temp }}\lima-${{ inputs.lima-home-suffix }}
INSTANCE: ${{ inputs.instance-name }}
TEMPLATE: ${{ inputs.template }}
VM_TYPE: ${{ inputs.vm-type }}
run: |
$ErrorActionPreference = 'Stop'
# $ErrorActionPreference does not propagate to native commands; wrap
# limactl so a non-zero exit aborts the whole script instead of
# silently continuing past the failure.
function Invoke-Limactl {
& .\_output\bin\limactl.exe --debug @args
if ($LASTEXITCODE -ne 0) { throw "limactl $($args -join ' ') exited $LASTEXITCODE" }
}
if (Test-Path $env:LIMA_HOME) { Remove-Item $env:LIMA_HOME -Recurse -Force }
New-Item -ItemType Directory -Path $env:LIMA_HOME | Out-Null
$createArgs = @('--tty=false', "--name=$env:INSTANCE")
if ($env:VM_TYPE) { $createArgs += "--vm-type=$env:VM_TYPE" }
$createArgs += $env:TEMPLATE
Invoke-Limactl create @createArgs
Invoke-Limactl start $env:INSTANCE
Invoke-Limactl shell --tty=false $env:INSTANCE -- uname -srm
'roundtrip' | Out-File -Encoding ascii "$env:LIMA_HOME\rt.txt"
Invoke-Limactl copy "$env:LIMA_HOME\rt.txt" "${env:INSTANCE}:/tmp/rt.txt"
Invoke-Limactl shell --tty=false $env:INSTANCE -- cat /tmp/rt.txt
# Cover the guest -> host direction too: it runs through different
# parseCopyPaths plumbing than the upload above.
Invoke-Limactl copy "${env:INSTANCE}:/tmp/rt.txt" "$env:LIMA_HOME\rt-back.txt"
$back = (Get-Content "$env:LIMA_HOME\rt-back.txt" -Raw).Trim()
if ($back -ne 'roundtrip') {
throw "round-trip mismatch: expected 'roundtrip', got '$back'"
}
Invoke-Limactl stop $env:INSTANCE
Invoke-Limactl delete --force $env:INSTANCE

- name: Dump Lima logs on failure
if: failure()
shell: pwsh
env:
LIMA_HOME: ${{ runner.temp }}\lima-${{ inputs.lima-home-suffix }}
INSTANCE: ${{ inputs.instance-name }}
run: |
$instDir = Join-Path $env:LIMA_HOME $env:INSTANCE
if (-not (Test-Path $instDir)) {
Write-Host "No instance directory at $instDir, nothing to dump."
exit 0
}
Get-ChildItem $instDir | Format-Table Name, Length, LastWriteTime
foreach ($name in 'ha.stdout.log','ha.stderr.log','serial.log','lima.yaml','ssh.config') {
$p = Join-Path $instDir $name
if (Test-Path $p) {
Write-Host ("===== {0} =====" -f $p)
Get-Content $p
}
}
75 changes: 75 additions & 0 deletions .github/actions/windows_plain_templates/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: 'windows plain templates'
description: >
Resolves Lima's template symlinks and copies templates/ to
_output/share/lima/templates/, replicating `make`'s TEMPLATES target without
needing MSYS2 / Git for Windows.

Several files under templates/ are git symlinks (mode 120000) pointing at
sibling files, and some chain (opensuse.yaml -> opensuse-leap.yaml ->
opensuse-leap-16.yaml). The working-tree representation varies: a runner
with Developer Mode (or admin) preserves them as NTFS symlinks; a plain
Windows checkout with core.symlinks=false writes 17-byte plaintext stubs.
Reading targets directly from git's object store makes resolution
independent of which representation the checkout used.

This action MUST run before windows_plain_host (which uninstalls Git for
Windows), because it shells out to `git ls-tree` and `git cat-file`.
runs:
using: composite
steps:
- name: Install templates (replicating make's TEMPLATES target)
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$srcRoot = 'templates'
$dstRoot = '_output\share\lima\templates'
if (Test-Path $dstRoot) { Remove-Item $dstRoot -Recurse -Force }
New-Item -ItemType Directory -Force -Path $dstRoot | Out-Null
Copy-Item -Path "$srcRoot\*" -Destination $dstRoot -Recurse -Force

$linkTargets = @{}
# Capture before iterating: a partial-output failure of `git ls-tree`
# would let the foreach run on truncated data, and the later
# `git cat-file` calls would overwrite $LASTEXITCODE before any
# check could see the original failure.
$lsTreeOutput = git ls-tree -r HEAD $srcRoot
if ($LASTEXITCODE -ne 0) { throw "git ls-tree failed: $LASTEXITCODE" }
foreach ($line in $lsTreeOutput) {
if ($line -notmatch '^120000\s') { continue }
$parts = $line -split "`t", 2
$sha = ($parts[0] -split '\s+')[2]
$posixPath = $parts[1]
$target = (git cat-file blob $sha).Trim()
if ($LASTEXITCODE -ne 0) { throw "git cat-file $sha failed: $LASTEXITCODE" }
$linkTargets[$posixPath] = $target
}

foreach ($posixPath in $linkTargets.Keys) {
$resolved = $posixPath
$depth = 0
while ($linkTargets.ContainsKey($resolved)) {
if ($depth++ -gt 16) { throw "Symlink chain too deep starting from $posixPath" }
$target = $linkTargets[$resolved]
$slash = $resolved.LastIndexOf('/')
$resolved = if ($slash -ge 0) { "$($resolved.Substring(0, $slash))/$target" } else { $target }
$segs = New-Object System.Collections.ArrayList
foreach ($s in ($resolved -split '/')) {
if ($s -eq '' -or $s -eq '.') { continue }
if ($s -eq '..') {
if ($segs.Count -gt 0) { $segs.RemoveAt($segs.Count - 1) | Out-Null }
continue
}
$segs.Add($s) | Out-Null
}
$resolved = $segs -join '/'
}
# Defensive: the '..' handling above silently underflows. Guard against
# a future symlink edit whose chain resolves outside templates/.
if (-not $resolved.StartsWith("$srcRoot/")) {
throw "Symlink $posixPath resolved to $resolved, which escapes $srcRoot/"
}
$srcFile = $resolved -replace '/', '\'
$dstFile = Join-Path '_output\share\lima' ($posixPath -replace '/', '\')
Write-Host "Resolving symlink: $posixPath -> $resolved"
Copy-Item -Force -Path $srcFile -Destination $dstFile
}
11 changes: 11 additions & 0 deletions .github/actions/windows_qemu_install/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
name: 'windows qemu install'
description: Installs QEMU on the Windows runner via winget.
runs:
using: composite
steps:
- name: Install QEMU
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
winget install --silent --accept-source-agreements --accept-package-agreements --disable-interactivity SoftwareFreedomConservancy.QEMU
if ($LASTEXITCODE -ne 0) { throw "winget install QEMU failed: $LASTEXITCODE" }
Loading
Loading