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 image: description: Container image to test required: false default: ghcr.io/dockur/windows:latest type: string callback: description: Guest script type required: false default: powershell type: string cpu: description: CPU model exposed to Windows required: false default: host type: string 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: ${{ inputs.image }} steps: - 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" case "$CALLBACK" in powershell) cat > "$RUNNER_TEMP/oem/ready.ps1" <<'POWERSHELL' param( [Parameter(Mandatory = $true)] [string]$Token ) $ErrorActionPreference = "Stop" $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() } } while ($true) { try { $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 { Start-Sleep -Seconds 10 } } POWERSHELL cat > "$RUNNER_TEMP/oem/install.bat" < "$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 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 = "" For Each system In systems caption = CStr(system.Caption) version = CStr(system.Version) build = CStr(system.BuildNumber) Exit For Next edition = ReadRegistry(shell, "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\EditionID") platform = GetPlatform(shell) temporary = "\\host.lan\Data\windows.tmp" destination = "\\host.lan\Data\windows.json" Do On Error Resume Next share = ReadTextFile(filesystem, "\\host.lan\Data\readme.txt") 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 If filesystem.FileExists(destination) Then filesystem.DeleteFile destination, True End If filesystem.MoveFile temporary, destination End If If Err.Number = 0 Then On Error GoTo 0 Exit Do End If Err.Clear On Error GoTo 0 WScript.Sleep 10000 Loop VBSCRIPT cat > "$RUNNER_TEMP/oem/install.bat" <> "$GITHUB_OUTPUT" - name: Pull image shell: bash run: | set -Eeuo pipefail docker pull "$IMAGE" docker image inspect "$IMAGE" \ --format 'Digest: {{index .RepoDigests 0}}' - name: Install and validate Windows shell: bash env: CPU: ${{ inputs.cpu }} 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 }} VERSION: ${{ inputs.version }} DISPLAY_NAME: ${{ inputs.name }} run: | set -Eeuo pipefail docker run --detach \ --name "$CONTAINER" \ --device /dev/kvm \ --device /dev/net/tun \ --cap-add NET_ADMIN \ --stop-timeout 120 \ --env "VERSION=$VERSION" \ --env "RAM_SIZE=half" \ --env "CPU_CORES=half" \ --env "CPU_MODEL=$CPU" \ --env "DISK_SIZE=64G" \ --volume "$RUNNER_TEMP/data:/shared" \ --volume "$RUNNER_TEMP/oem:/oem:ro" \ --volume "$RUNNER_TEMP/storage:/storage" \ "$IMAGE" echo echo "Container log:" echo "------------------------------------------------------------" docker logs \ --follow \ --timestamps \ "$CONTAINER" & logs_pid="$!" stop_logs() { kill "$logs_pid" 2>/dev/null || true wait "$logs_pid" 2>/dev/null || true } trap stop_logs EXIT deadline=$((SECONDS + 9000)) reboot_timeout=1800 minimum_reboots=1 boot_loop_limit=5 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 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." exit 1 fi if grep -Eqi \ 'CDBOOT: Cannot boot from CD.*Code: 5' \ <<< "$container_log"; then echo echo "------------------------------------------------------------" echo "The installation media could not be booted." exit 1 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 (( 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" exit 1 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 "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 "$CONTAINER" 2>/dev/null || true