name: Installation on: workflow_call: inputs: name: description: Display name for the Windows version required: true type: string version: description: VERSION value passed to the container required: true type: string runner: description: GitHub Actions runner required: false default: ubuntu-24.04 type: string branch: description: Repository branch to build required: false default: dev type: string callback: description: Guest script type required: false default: powershell type: string machine: description: QEMU machine type exposed to Windows required: false default: "" type: string cpu: description: CPU model exposed to Windows required: false default: "" type: string cpu_flags: description: Additional CPU flags exposed to Windows required: false default: "" type: string disk_type: description: Disk controller type exposed to Windows required: false default: "" type: string network: description: Network backend exposed to Windows required: false default: "" type: string adapter: description: Network adapter exposed to Windows required: false default: "" type: string kill_on_failure: description: Stop the workflow when an installation failure is detected required: false default: true type: boolean expected_caption: description: Text expected in the Windows caption required: true type: string expected_edition: description: Expected Windows EditionID required: false default: "" type: string minimum_build: description: Minimum acceptable Windows build number required: true type: number platform: description: Expected Windows platform required: true type: string permissions: contents: read jobs: install: name: ${{ inputs.name }} runs-on: ${{ inputs.runner }} timeout-minutes: 180 env: CONTAINER: windows-test IMAGE: windows-validation:local TUNNEL: windows-test-tunnel steps: - name: Resolve branch id: branch shell: bash env: REQUESTED_BRANCH: ${{ inputs.branch }} GITHUB_TOKEN: ${{ github.token }} run: | set -Eeuo pipefail authorization="$( printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w 0 )" if git \ -c "http.extraheader=Authorization: basic $authorization" \ ls-remote \ --exit-code \ --heads \ "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" \ "refs/heads/$REQUESTED_BRANCH" > /dev/null 2>&1; then branch="$REQUESTED_BRANCH" else branch="master" echo "Branch '$REQUESTED_BRANCH' does not exist; using '$branch'." fi echo "branch=$branch" >> "$GITHUB_OUTPUT" - name: Checkout branch uses: actions/checkout@v7 with: ref: ${{ steps.branch.outputs.branch }} - name: Check KVM shell: bash run: | set -Eeuo pipefail test -c /dev/kvm - name: Free disk space shell: bash run: | set -Eeuo pipefail sudo rm -rf \ /usr/local/.ghcup \ /usr/local/lib/android \ /usr/local/lib/node_modules \ /usr/local/share/boost \ /usr/local/share/chromium \ /usr/local/share/powershell \ /usr/share/dotnet \ /usr/share/swift \ /opt/az \ /opt/ghc \ /opt/google \ /opt/hostedtoolcache \ /opt/microsoft \ /opt/pipx sudo apt-get clean sudo rm -rf \ /var/cache/apt/* \ /var/lib/apt/lists/* docker system prune \ --all \ --force \ --volumes > /dev/null || true available_kb="$(df --output=avail / | tail -1)" available_gb="$((available_kb / 1024 / 1024))" echo "Available disk space: ${available_gb} GB" if (( available_gb < 40 )); then echo "At least 40 GB of free space is required." exit 1 fi - name: Prepare installation test id: test shell: bash env: CALLBACK: ${{ inputs.callback }} run: | set -Eeuo pipefail case "$CALLBACK" in powershell | legacy) ;; *) echo "Unsupported guest script type: $CALLBACK" exit 1 ;; esac token="$(cat /proc/sys/kernel/random/uuid)" mkdir -p \ "$RUNNER_TEMP/data" \ "$RUNNER_TEMP/oem" \ "$RUNNER_TEMP/storage" printf '%s\n' "$token" > "$RUNNER_TEMP/data/readme.txt" cat > "$RUNNER_TEMP/oem/sync-log.bat" <<'BATCH' @echo off setlocal :sync if exist C:\OEM\install.log ( copy /Y C:\OEM\install.log \\host.lan\Data\install.tmp >nul 2>&1 if not errorlevel 1 ( move /Y \\host.lan\Data\install.tmp \\host.lan\Data\install.log >nul 2>&1 ) ) if /I "%~1"=="once" exit /B if exist C:\OEM\install.done exit /B ping 127.0.0.1 -n 6 >nul goto sync BATCH cat > "$RUNNER_TEMP/oem/shutdown.vbs" <<'VBSCRIPT' On Error Resume Next Dim service Dim systems Dim system Set service = GetObject("winmgmts:{impersonationLevel=impersonate,(Shutdown)}!\\.\root\cimv2") Set systems = service.ExecQuery("SELECT * FROM Win32_OperatingSystem") For Each system In systems system.Win32Shutdown 5 Next VBSCRIPT cat > "$RUNNER_TEMP/oem/watchdog.vbs" <<'VBSCRIPT' Option Explicit Const ForAppending = 8 Dim filesystem Dim file Dim shell Dim service Dim systems Dim system WScript.Sleep 180000 Set filesystem = CreateObject("Scripting.FileSystemObject") If filesystem.FileExists("C:\OEM\install.done") Then WScript.Quit 0 End If On Error Resume Next Set file = filesystem.OpenTextFile("C:\OEM\install.log", ForAppending, True) file.WriteLine Now & " - Callback failed for 3 minutes." file.Close Set file = filesystem.CreateTextFile("C:\OEM\install.done", True) file.Close Set shell = CreateObject("WScript.Shell") shell.Run "shutdown.exe -s -f -t 0", 0, False Set service = GetObject("winmgmts:{impersonationLevel=impersonate,(Shutdown)}!\\.\root\cimv2") Set systems = service.ExecQuery("SELECT * FROM Win32_OperatingSystem") For Each system In systems system.Win32Shutdown 5 Next VBSCRIPT case "$CALLBACK" in powershell) cat > "$RUNNER_TEMP/oem/ready.ps1" <<'POWERSHELL' param( [Parameter(Mandatory = $true)] [string]$Token ) $ErrorActionPreference = "Stop" while ($true) { try { $windows = Get-CimInstance Win32_OperatingSystem $registry = Get-ItemProperty ` "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" $platform = switch ( $env:PROCESSOR_ARCHITECTURE.ToUpperInvariant() ) { "AMD64" { "x64" } "ARM64" { "arm64" } "X86" { "x86" } default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() } } $share = ( Get-Content ` -LiteralPath "\\host.lan\Data\readme.txt" ` -Raw ).Trim() $result = @{ token = $Token caption = [string]$windows.Caption edition = [string]$registry.EditionID version = [string]$windows.Version build = [string]$windows.BuildNumber platform = $platform share = $share } $json = $result | ConvertTo-Json -Compress $temporary = "\\host.lan\Data\windows.tmp" $destination = "\\host.lan\Data\windows.json" $encoding = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText( $temporary, $json, $encoding ) Move-Item ` -LiteralPath $temporary ` -Destination $destination ` -Force break } catch { Write-Output "$(Get-Date -Format s) - Callback retry: $($_.Exception.Message)" Start-Sleep -Seconds 10 } } POWERSHELL cat > "$RUNNER_TEMP/oem/install.bat" <nul start "" /B cmd.exe /C C:\OEM\sync-log.bat start "" /B cscript.exe //B //NoLogo C:\OEM\watchdog.vbs powershell.exe -NoProfile -ExecutionPolicy Bypass ^ -File C:\OEM\ready.ps1 ^ -Token "$token" set "result=%errorlevel%" if not "%result%"=="0" ( echo %date% %time% - Callback failed.>>C:\OEM\install.log type nul > C:\OEM\install.done shutdown.exe -s -f -t 0 >nul 2>&1 if errorlevel 1 cscript.exe //B //NoLogo C:\OEM\shutdown.vbs exit /B %result% ) type nul > C:\OEM\install.done call C:\OEM\sync-log.bat once exit /B %result% EOF ;; legacy) cat > "$RUNNER_TEMP/oem/ready.vbs" <<'VBSCRIPT' Option Explicit Const ForReading = 1 Const ForWriting = 2 Dim arguments Dim token Set arguments = WScript.Arguments If arguments.Count < 1 Then WScript.Quit 1 End If token = arguments(0) Function EscapeJson(value) Dim result result = CStr(value) result = Replace(result, "\", "\\") result = Replace(result, Chr(34), "\" & Chr(34)) result = Replace(result, vbCr, "\r") result = Replace(result, vbLf, "\n") result = Replace(result, vbTab, "\t") EscapeJson = result End Function Function ReadRegistry(shell, path) Dim value value = "" On Error Resume Next value = shell.RegRead(path) If Err.Number <> 0 Then Err.Clear value = "" End If On Error GoTo 0 ReadRegistry = CStr(value) End Function Function ReadTextFile(filesystem, path) Dim file Dim value value = "" Set file = filesystem.OpenTextFile(path, ForReading, False) value = file.ReadAll file.Close value = Replace(value, vbCr, "") value = Replace(value, vbLf, "") ReadTextFile = value End Function Function GetPlatform(shell) Dim architecture architecture = UCase(shell.ExpandEnvironmentStrings("%PROCESSOR_ARCHITECTURE%")) Select Case architecture Case "AMD64" GetPlatform = "x64" Case "ARM64" GetPlatform = "arm64" Case "X86" GetPlatform = "x86" Case Else GetPlatform = LCase(architecture) End Select End Function Dim filesystem Dim shell Dim locator Dim service Dim systems Dim system Dim caption Dim edition Dim version Dim build Dim platform Dim share Dim json Dim temporary Dim destination Dim file Dim success temporary = "\\host.lan\Data\windows.tmp" destination = "\\host.lan\Data\windows.json" Do success = False On Error Resume Next Err.Clear Set filesystem = CreateObject("Scripting.FileSystemObject") Set shell = CreateObject("WScript.Shell") Set locator = CreateObject("WbemScripting.SWbemLocator") Set service = locator.ConnectServer(".", "root\cimv2") Set systems = service.ExecQuery("SELECT Caption, Version, BuildNumber FROM Win32_OperatingSystem") caption = "" version = "" build = "" If Err.Number = 0 Then For Each system In systems caption = CStr(system.Caption) version = CStr(system.Version) build = CStr(system.BuildNumber) Exit For Next End If If Err.Number = 0 Then edition = ReadRegistry(shell, "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\EditionID") platform = GetPlatform(shell) share = ReadTextFile(filesystem, "\\host.lan\Data\readme.txt") End If If Err.Number = 0 Then json = _ "{" & _ """token"":""" & EscapeJson(token) & """," & _ """caption"":""" & EscapeJson(caption) & """," & _ """edition"":""" & EscapeJson(edition) & """," & _ """version"":""" & EscapeJson(version) & """," & _ """build"":""" & EscapeJson(build) & """," & _ """platform"":""" & EscapeJson(platform) & """," & _ """share"":""" & EscapeJson(share) & """" & _ "}" Set file = filesystem.OpenTextFile(temporary, ForWriting, True) file.Write json file.Close End If If Err.Number = 0 Then If filesystem.FileExists(destination) Then filesystem.DeleteFile destination, True End If filesystem.MoveFile temporary, destination End If If Err.Number = 0 Then success = True Else WScript.Echo _ Now & _ " - Callback retry: 0x" & _ Hex(Err.Number) & _ " - " & _ Err.Description End If Err.Clear On Error GoTo 0 If success Then Exit Do End If WScript.Sleep 10000 Loop VBSCRIPT cat > "$RUNNER_TEMP/oem/install.bat" <nul start "" /B cmd.exe /C C:\OEM\sync-log.bat start "" /B cscript.exe //B //NoLogo C:\OEM\watchdog.vbs cscript.exe //B //NoLogo C:\OEM\ready.vbs "$token" set "result=%errorlevel%" if not "%result%"=="0" ( echo %date% %time% - Callback failed.>>C:\OEM\install.log type nul > C:\OEM\install.done shutdown.exe -s -f -t 0 >nul 2>&1 if errorlevel 1 cscript.exe //B //NoLogo C:\OEM\shutdown.vbs exit /B %result% ) type nul > C:\OEM\install.done call C:\OEM\sync-log.bat once exit /B %result% EOF ;; esac echo "token=$token" >> "$GITHUB_OUTPUT" - name: Build image shell: bash run: | set -Eeuo pipefail echo "Building branch: ${{ steps.branch.outputs.branch }}" echo "Commit: $(git rev-parse HEAD)" for attempt in 1 2 3; do if docker build \ --tag "$IMAGE" \ . >/dev/null 2>/dev/null; then break fi if (( attempt == 3 )); then echo "Image build failed after $attempt attempts." exit 1 fi delay=$((attempt * 15)) echo "Image build failed, retrying in $delay seconds..." sleep "$delay" done docker image inspect "$IMAGE" \ --format 'Image ID: {{.Id}}' - name: Start Windows container shell: bash env: ADAPTER: ${{ inputs.adapter }} CPU: ${{ inputs.cpu }} CPU_FLAGS: ${{ inputs.cpu_flags }} DISK_TYPE: ${{ inputs.disk_type }} MACHINE: ${{ inputs.machine }} NETWORK: ${{ inputs.network }} VERSION: ${{ inputs.version }} run: | set -Eeuo pipefail docker run --detach \ --name "$CONTAINER" \ --device /dev/kvm \ --device /dev/net/tun \ --cap-add NET_ADMIN \ --stop-timeout 120 \ --env "MACHINE=$MACHINE" \ --env "VERSION=$VERSION" \ --env "RAM_SIZE=half" \ --env "CPU_CORES=half" \ --env "DISK_SIZE=64G" \ --env "CPU_MODEL=$CPU" \ --env "CPU_FLAGS=$CPU_FLAGS" \ --env "DISK_TYPE=$DISK_TYPE" \ --env "NETWORK=$NETWORK" \ --env "ADAPTER=$ADAPTER" \ --env "LOG=Y" \ --env "DEBUG=Y" \ --env "LOSSY=Y" \ --volume "$RUNNER_TEMP/data:/shared" \ --volume "$RUNNER_TEMP/oem:/oem:ro" \ --volume "$RUNNER_TEMP/storage:/storage" \ "$IMAGE" - name: Start noVNC tunnel if: ${{ !inputs.kill_on_failure }} id: tunnel shell: bash run: | set -Eeuo pipefail if docker run --detach \ --name "$TUNNEL" \ --network "container:$CONTAINER" \ cloudflare/cloudflared:latest \ tunnel \ --no-autoupdate \ --url http://127.0.0.1:8006 > /dev/null 2>/dev/null; then novnc_url="" for _ in {1..30}; do novnc_url="$( docker logs "$TUNNEL" 2>&1 | grep -Eo 'https://[-a-z0-9]+\.trycloudflare\.com' | tail -n 1 || true )" [ -n "$novnc_url" ] && break sleep 2 done if [ -n "$novnc_url" ]; then echo echo "::notice title=noVNC viewer::$novnc_url" echo "noVNC viewer: $novnc_url" echo "Warning: this temporary URL is publicly accessible." echo "url=$novnc_url" >> "$GITHUB_OUTPUT" { echo "### noVNC viewer" echo echo "[$novnc_url]($novnc_url)" echo echo "> This temporary URL is publicly accessible while the job is running." } >> "$GITHUB_STEP_SUMMARY" else echo "::warning::Failed to obtain a noVNC tunnel URL." docker logs "$TUNNEL" 2>&1 || true fi else echo "::warning::Failed to start the noVNC tunnel." fi - name: Install and validate Windows shell: bash env: KILL_ON_FAILURE: ${{ inputs.kill_on_failure }} EXPECTED_TOKEN: ${{ steps.test.outputs.token }} EXPECTED_CAPTION: ${{ inputs.expected_caption }} EXPECTED_EDITION: ${{ inputs.expected_edition }} EXPECTED_PLATFORM: ${{ inputs.platform }} MINIMUM_BUILD: ${{ inputs.minimum_build }} DISPLAY_NAME: ${{ inputs.name }} run: | set -Eeuo pipefail echo echo "Container log:" echo "------------------------------------------------------------" docker logs \ --follow \ --timestamps \ "$CONTAINER" & logs_pid="$!" guest_log="$RUNNER_TEMP/data/install.log" ( offset=0 snapshot="$RUNNER_TEMP/install-log.snapshot" while true; do if cp "$guest_log" "$snapshot" 2>/dev/null; then size="$(stat -c %s "$snapshot" 2>/dev/null || echo 0)" if (( size < offset )); then offset=0 fi if (( size > offset )); then tail -c "+$((offset + 1))" "$snapshot" 2>/dev/null | sed -u 's/^/[install.bat] /' || true offset="$size" fi fi sleep 2 done ) & guest_logs_pid="$!" stop_logs() { kill "$logs_pid" "$guest_logs_pid" 2>/dev/null || true wait "$logs_pid" "$guest_logs_pid" 2>/dev/null || true } trap stop_logs EXIT failure_checks_disabled=0 handle_failure() { local message="$1" if [[ "$KILL_ON_FAILURE" == "true" ]]; then return 1 fi echo echo "Automatic termination is disabled by kill_on_failure." echo "The container and noVNC tunnel will remain available." echo "::warning title=Failure detected::$message" failure_checks_disabled=1 return 0 } deadline=$((SECONDS + 9000)) reboot_timeout=1800 minimum_reboots=1 boot_loop_limit=10 first_bios_start=-1 while (( SECONDS < deadline )); do state="$( docker inspect \ --format '{{.State.Status}}' \ "$CONTAINER" 2>/dev/null || true )" if [[ "$state" != "running" ]]; then echo echo "------------------------------------------------------------" echo "Container stopped before Windows became ready." echo "Container state: ${state:-missing}" exit 1 fi container_log="$(docker logs "$CONTAINER" 2>&1 || true)" if (( failure_checks_disabled == 0 )) && grep -Eqi \ 'KVM internal error|KVM: entry failed|hardware error 0x[0-9a-f]+|Triple fault' \ <<< "$container_log"; then echo echo "------------------------------------------------------------" echo "Detected a fatal QEMU or KVM error." if ! handle_failure "Detected a fatal QEMU or KVM error."; then exit 1 fi fi if (( failure_checks_disabled == 0 )) && grep -Eqi \ 'CDBOOT: Cannot boot from CD.*Code: 5' \ <<< "$container_log"; then echo echo "------------------------------------------------------------" echo "The installation media could not be booted." if ! handle_failure "The installation media could not be booted."; then exit 1 fi fi bios_starts="$( grep -Fci 'SeaBIOS (version ' <<< "$container_log" || true )" hard_disk_boots="$( grep -Fci 'Booting from Hard Disk' <<< "$container_log" || true )" dvd_boots="$( grep -Fci 'Booting from DVD/CD' <<< "$container_log" || true )" not_bootable_disk="$( grep -Fci \ 'Boot failed: not a bootable disk' \ <<< "$container_log" || true )" unreadable_boot_disk="$( grep -Fci \ 'Boot failed: could not read the boot disk' \ <<< "$container_log" || true )" no_bootable_device="$( grep -Fci 'No bootable device.' <<< "$container_log" || true )" bootmgr_missing="$( grep -Fci 'BOOTMGR is missing' <<< "$container_log" || true )" reboots=$((bios_starts > 0 ? bios_starts - 1 : 0)) if (( first_bios_start < 0 && bios_starts > 0 )); then first_bios_start=$SECONDS fi if (( failure_checks_disabled == 0 && first_bios_start >= 0 && SECONDS - first_bios_start >= reboot_timeout && reboots < minimum_reboots )); then echo echo "------------------------------------------------------------" echo "The installation did not reboot within" \ "$((reboot_timeout / 60)) minutes after the first BIOS start." echo "Observed reboots: $reboots" echo "Required reboots: $minimum_reboots" if ! handle_failure "The installation did not reboot in time."; then exit 1 fi fi if (( bios_starts >= boot_loop_limit )) && (( hard_disk_boots >= boot_loop_limit || dvd_boots >= boot_loop_limit || not_bootable_disk >= boot_loop_limit || unreadable_boot_disk >= boot_loop_limit || no_bootable_device >= boot_loop_limit || bootmgr_missing >= boot_loop_limit )); then echo echo "------------------------------------------------------------" echo "Detected a repeated BIOS boot loop." echo echo "SeaBIOS starts: $bios_starts" echo "Hard disk boots: $hard_disk_boots" echo "DVD boots: $dvd_boots" echo "Not-bootable disk failures: $not_bootable_disk" echo "Unreadable boot-disk failures: $unreadable_boot_disk" echo "No-bootable-device failures: $no_bootable_device" echo "BOOTMGR failures: $bootmgr_missing" exit 1 fi response="" if [[ -s "$RUNNER_TEMP/data/windows.json" ]]; then response="$(cat "$RUNNER_TEMP/data/windows.json")" fi if [[ -n "$response" ]]; then stop_logs trap - EXIT echo echo "------------------------------------------------------------" echo "Received response:" if ! jq . <<< "$response"; then echo "$response" echo "The guest returned invalid JSON." exit 1 fi token="$(jq -r '.token // empty' <<< "$response")" caption="$(jq -r '.caption // empty' <<< "$response")" edition="$(jq -r '.edition // empty' <<< "$response")" version="$(jq -r '.version // empty' <<< "$response")" build="$(jq -r '.build // empty' <<< "$response")" platform="$(jq -r '.platform // empty' <<< "$response")" share="$(jq -r '.share // empty' <<< "$response")" if [[ "$token" != "$EXPECTED_TOKEN" ]]; then echo "The response token does not match." exit 1 fi if [[ "$share" != "$EXPECTED_TOKEN" ]]; then echo "Failed to read \\\\host.lan\\Data\\readme.txt." echo "Expected contents:" echo " $EXPECTED_TOKEN" echo "Received:" echo " ${share:-empty}" exit 1 fi normalized_caption="${caption//\(R\)/}" normalized_expected_caption="${EXPECTED_CAPTION//\(R\)/}" if [[ "$normalized_caption" != *"$normalized_expected_caption"* ]]; then echo "Expected caption containing:" echo " $EXPECTED_CAPTION" echo "Received:" echo " $caption" exit 1 fi normalized_edition="${edition%Eval}" normalized_expected_edition="${EXPECTED_EDITION%Eval}" if [[ -n "$EXPECTED_EDITION" && "$normalized_edition" != "$normalized_expected_edition" ]]; then echo "Expected edition: $EXPECTED_EDITION" echo "Received edition: $edition" exit 1 fi if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Unexpected Windows version: $version" exit 1 fi if [[ ! "$build" =~ ^[0-9]+$ ]]; then echo "Invalid Windows build number: $build" exit 1 fi if (( build < MINIMUM_BUILD )); then echo "Expected build $MINIMUM_BUILD or newer." echo "Received build: $build" exit 1 fi if [[ "$platform" != "$EXPECTED_PLATFORM" ]]; then echo "Expected platform:" echo " $EXPECTED_PLATFORM" echo "Received platform:" echo " $platform" exit 1 fi echo echo "$DISPLAY_NAME installed successfully." echo "Version: $version" echo "Build: $build" echo "Platform: $platform" echo "Shared file: accessible" exit 0 fi sleep 10 done echo echo "------------------------------------------------------------" echo "Timed out waiting for Windows installation." exit 1 - name: Cleanup if: always() shell: bash run: | docker rm --force "$TUNNEL" 2>/dev/null || true docker rm --force "$CONTAINER" 2>/dev/null || true