mirror of
https://github.com/dockur/windows.git
synced 2026-08-03 12:37:20 +01:00
1268 lines
38 KiB
YAML
1268 lines
38 KiB
YAML
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
|
|
|
|
enable_tunnel:
|
|
description: Open a temporary public noVNC tunnel
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
|
|
kill_on_failure:
|
|
description: Stop the workflow when an installation failure is detected
|
|
required: false
|
|
default: true
|
|
type: boolean
|
|
|
|
install_timeout:
|
|
description: Maximum installation time in seconds
|
|
required: false
|
|
default: 9000
|
|
type: number
|
|
|
|
reboot_timeout:
|
|
description: Maximum time before the first reboot in seconds
|
|
required: false
|
|
default: 1800
|
|
type: number
|
|
|
|
minimum_reboots:
|
|
description: Minimum required reboot count
|
|
required: false
|
|
default: 1
|
|
type: number
|
|
|
|
boot_loop_limit:
|
|
description: Number of repeated boots considered a boot loop
|
|
required: false
|
|
default: 10
|
|
type: number
|
|
|
|
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/validation.token"
|
|
printf '%s\n' "$token" > "$RUNNER_TEMP/oem/validation.token"
|
|
|
|
cat > "$RUNNER_TEMP/oem/sync-log.bat" <<'BATCH'
|
|
@echo off
|
|
setlocal
|
|
|
|
set "mode=%~1"
|
|
set "lock=C:\OEM\sync-log.lock"
|
|
set /A retries=0 >nul
|
|
|
|
:sync
|
|
call :sync_file
|
|
|
|
if /I not "%mode%"=="once" goto continuous
|
|
if not errorlevel 1 exit /B 0
|
|
|
|
set /A retries+=1 >nul
|
|
if %retries% GEQ 30 exit /B 1
|
|
|
|
ping 127.0.0.1 -n 2 >nul
|
|
goto sync
|
|
|
|
:continuous
|
|
if exist C:\OEM\install.done exit /B
|
|
|
|
ping 127.0.0.1 -n 6 >nul
|
|
goto sync
|
|
|
|
:sync_file
|
|
if not exist C:\OEM\install.log exit /B 1
|
|
|
|
2>nul mkdir "%lock%"
|
|
if errorlevel 1 exit /B 1
|
|
|
|
copy /Y C:\OEM\install.log \\host.lan\Data\install.tmp >nul 2>&1
|
|
if errorlevel 1 goto sync_failed
|
|
|
|
move /Y \\host.lan\Data\install.tmp \\host.lan\Data\install.log >nul 2>&1
|
|
if errorlevel 1 goto sync_failed
|
|
|
|
rmdir "%lock%" >nul 2>&1
|
|
exit /B 0
|
|
|
|
:sync_failed
|
|
rmdir "%lock%" >nul 2>&1
|
|
exit /B 1
|
|
BATCH
|
|
|
|
cat > "$RUNNER_TEMP/oem/watchdog.vbs" <<'VBSCRIPT'
|
|
Option Explicit
|
|
|
|
Dim filesystem
|
|
Dim file
|
|
Dim result
|
|
Dim shell
|
|
|
|
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
|
|
|
|
WScript.Echo Now & " - Callback failed for 3 minutes."
|
|
|
|
Set shell = CreateObject("WScript.Shell")
|
|
result = shell.Run("cmd.exe /C C:\OEM\sync-log.bat once", 0, True)
|
|
|
|
Set file = filesystem.CreateTextFile("C:\OEM\install.done", True)
|
|
file.Close
|
|
|
|
If result <> 0 Then
|
|
shell.Run "cmd.exe /C echo VALIDATION_LOG_SYNC_FAILED^>COM1", 0, True
|
|
End If
|
|
|
|
shell.Run "cmd.exe /C echo VALIDATION_CALLBACK_FAILED^>COM1", 0, True
|
|
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\validation.token" `
|
|
-Raw
|
|
).Trim()
|
|
|
|
$shareTest = "\\host.lan\Data\validation-write-$Token.tmp"
|
|
$shareValue = "write-test-$Token"
|
|
$encoding = New-Object System.Text.UTF8Encoding($false)
|
|
|
|
try {
|
|
[System.IO.File]::WriteAllText(
|
|
$shareTest,
|
|
$shareValue,
|
|
$encoding
|
|
)
|
|
|
|
if (
|
|
[System.IO.File]::ReadAllText($shareTest) -ne
|
|
$shareValue
|
|
) {
|
|
throw "Shared-folder readback verification failed."
|
|
}
|
|
}
|
|
finally {
|
|
if (Test-Path -LiteralPath $shareTest) {
|
|
Remove-Item -LiteralPath $shareTest -Force
|
|
}
|
|
}
|
|
|
|
$oemFile = (
|
|
Get-Content `
|
|
-LiteralPath "C:\OEM\validation.token" `
|
|
-Raw
|
|
).Trim()
|
|
|
|
$addresses = [System.Net.Dns]::GetHostAddresses(
|
|
"www.msftconnecttest.com"
|
|
)
|
|
|
|
if ($addresses.Count -eq 0) {
|
|
throw "Internet DNS lookup returned no addresses."
|
|
}
|
|
|
|
$internet = (
|
|
Invoke-WebRequest `
|
|
-Uri "http://www.msftconnecttest.com/connecttest.txt" `
|
|
-UseBasicParsing `
|
|
-TimeoutSec 30
|
|
).Content.Trim()
|
|
|
|
if ($internet -ne "Microsoft Connect Test") {
|
|
throw "Internet HTTP test returned unexpected content."
|
|
}
|
|
|
|
$result = @{
|
|
token = $Token
|
|
caption = [string]$windows.Caption
|
|
edition = [string]$registry.EditionID
|
|
version = [string]$windows.Version
|
|
build = [string]$windows.BuildNumber
|
|
platform = $platform
|
|
oem_file = $oemFile
|
|
share = $share
|
|
share_write = "ok"
|
|
internet = "ok"
|
|
}
|
|
|
|
$json = $result | ConvertTo-Json -Compress
|
|
$serialResult = "C:\OEM\validation.result"
|
|
$serialValue = "VALIDATION_RESULT=$json`r`n"
|
|
|
|
[System.IO.File]::WriteAllText(
|
|
$serialResult,
|
|
$serialValue,
|
|
$encoding
|
|
)
|
|
|
|
break
|
|
}
|
|
catch {
|
|
Write-Output "$(Get-Date -Format s) - Callback retry: $($_.Exception.Message)"
|
|
Start-Sleep -Seconds 10
|
|
}
|
|
}
|
|
POWERSHELL
|
|
|
|
cat > "$RUNNER_TEMP/oem/install.bat" <<EOF
|
|
@echo off
|
|
del /Q C:\OEM\install.done 2>nul
|
|
del /Q C:\OEM\validation.result 2>nul
|
|
rmdir /S /Q C:\OEM\sync-log.lock 2>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.
|
|
call C:\OEM\sync-log.bat once
|
|
if errorlevel 1 echo VALIDATION_LOG_SYNC_FAILED>COM1
|
|
type nul > C:\OEM\install.done
|
|
echo VALIDATION_CALLBACK_FAILED>COM1
|
|
exit /B %result%
|
|
)
|
|
|
|
call C:\OEM\sync-log.bat once
|
|
|
|
if errorlevel 1 (
|
|
type nul > C:\OEM\install.done
|
|
echo VALIDATION_LOG_SYNC_FAILED>COM1
|
|
echo VALIDATION_CALLBACK_FAILED>COM1
|
|
exit /B 1
|
|
)
|
|
|
|
type C:\OEM\validation.result > COM1
|
|
|
|
if errorlevel 1 (
|
|
exit /B 1
|
|
)
|
|
|
|
type nul > C:\OEM\install.done
|
|
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 TestSharedFolder(filesystem, token)
|
|
Dim path
|
|
Dim expected
|
|
Dim file
|
|
Dim actual
|
|
|
|
path = "\\host.lan\Data\validation-write-" & token & ".tmp"
|
|
expected = "write-test-" & token
|
|
|
|
Set file = filesystem.CreateTextFile(path, True)
|
|
file.Write expected
|
|
file.Close
|
|
|
|
Set file = filesystem.OpenTextFile(path, ForReading, False)
|
|
actual = file.ReadAll
|
|
file.Close
|
|
|
|
If actual <> expected Then
|
|
Err.Raise vbObjectError + 1, "TestSharedFolder", _
|
|
"Shared-folder readback verification failed."
|
|
End If
|
|
|
|
filesystem.DeleteFile path, True
|
|
TestSharedFolder = "ok"
|
|
End Function
|
|
|
|
Function TestInternet()
|
|
Dim request
|
|
Dim response
|
|
|
|
Set request = CreateObject("WinHttp.WinHttpRequest.5.1")
|
|
request.SetTimeouts 30000, 30000, 30000, 30000
|
|
request.Open "GET", "http://www.msftncsi.com/ncsi.txt", False
|
|
request.Send
|
|
|
|
If request.Status <> 200 Then
|
|
Err.Raise vbObjectError + 2, "TestInternet", _
|
|
"Internet HTTP test returned status " & CStr(request.Status) & "."
|
|
End If
|
|
|
|
response = Replace(request.ResponseText, vbCr, "")
|
|
response = Replace(response, vbLf, "")
|
|
|
|
If response <> "Microsoft NCSI" Then
|
|
Err.Raise vbObjectError + 3, "TestInternet", _
|
|
"Internet HTTP test returned unexpected content."
|
|
End If
|
|
|
|
TestInternet = "ok"
|
|
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 oemFile
|
|
Dim share
|
|
Dim shareWrite
|
|
Dim internet
|
|
|
|
Dim json
|
|
Dim errorMessage
|
|
Dim file
|
|
Dim success
|
|
|
|
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)
|
|
oemFile = ReadTextFile(filesystem, "C:\OEM\validation.token")
|
|
share = ReadTextFile(filesystem, "\\host.lan\Data\validation.token")
|
|
shareWrite = TestSharedFolder(filesystem, token)
|
|
internet = TestInternet()
|
|
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) & """," & _
|
|
"""oem_file"":""" & EscapeJson(oemFile) & """," & _
|
|
"""share"":""" & EscapeJson(share) & """," & _
|
|
"""share_write"":""" & EscapeJson(shareWrite) & """," & _
|
|
"""internet"":""" & EscapeJson(internet) & """" & _
|
|
"}"
|
|
|
|
Set file = filesystem.OpenTextFile("C:\OEM\validation.result", ForWriting, True)
|
|
file.WriteLine "VALIDATION_RESULT=" & json
|
|
file.Close
|
|
End If
|
|
|
|
If Err.Number = 0 Then
|
|
success = True
|
|
Else
|
|
errorMessage = _
|
|
Now & _
|
|
" - Callback retry: 0x" & _
|
|
Hex(Err.Number) & _
|
|
" - " & _
|
|
Err.Description
|
|
|
|
WScript.Echo errorMessage
|
|
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" <<EOF
|
|
@echo off
|
|
del /Q C:\OEM\install.done 2>nul
|
|
del /Q C:\OEM\validation.result 2>nul
|
|
rmdir /S /Q C:\OEM\sync-log.lock 2>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.
|
|
call C:\OEM\sync-log.bat once
|
|
if errorlevel 1 echo VALIDATION_LOG_SYNC_FAILED>COM1
|
|
type nul > C:\OEM\install.done
|
|
echo VALIDATION_CALLBACK_FAILED>COM1
|
|
exit /B %result%
|
|
)
|
|
|
|
call C:\OEM\sync-log.bat once
|
|
|
|
if errorlevel 1 (
|
|
type nul > C:\OEM\install.done
|
|
echo VALIDATION_LOG_SYNC_FAILED>COM1
|
|
echo VALIDATION_CALLBACK_FAILED>COM1
|
|
exit /B 1
|
|
)
|
|
|
|
type C:\OEM\validation.result > COM1
|
|
|
|
if errorlevel 1 (
|
|
exit /B 1
|
|
)
|
|
|
|
type nul > C:\OEM\install.done
|
|
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"
|
|
|
|
echo
|
|
echo "Container mounts:"
|
|
docker inspect "$CONTAINER" \
|
|
--format '{{range .Mounts}}{{println .Source "->" .Destination}}{{end}}'
|
|
|
|
- name: Start noVNC tunnel
|
|
if: ${{ inputs.enable_tunnel }}
|
|
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 }}
|
|
INSTALL_TIMEOUT: ${{ inputs.install_timeout }}
|
|
REBOOT_TIMEOUT: ${{ inputs.reboot_timeout }}
|
|
MINIMUM_REBOOTS: ${{ inputs.minimum_reboots }}
|
|
BOOT_LOOP_LIMIT: ${{ inputs.boot_loop_limit }}
|
|
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"
|
|
guest_log_offset="$RUNNER_TEMP/install-log.offset"
|
|
guest_log_snapshot="$RUNNER_TEMP/install-log.snapshot"
|
|
|
|
printf '0\n' > "$guest_log_offset"
|
|
|
|
print_guest_log() {
|
|
local offset
|
|
local size
|
|
|
|
offset="$(cat "$guest_log_offset" 2>/dev/null || echo 0)"
|
|
|
|
if ! [[ "$offset" =~ ^[0-9]+$ ]]; then
|
|
offset=0
|
|
fi
|
|
|
|
if cp "$guest_log" "$guest_log_snapshot" 2>/dev/null; then
|
|
size="$(stat -c %s "$guest_log_snapshot" 2>/dev/null || echo 0)"
|
|
|
|
if (( size < offset )); then
|
|
offset=0
|
|
fi
|
|
|
|
if (( size > offset )); then
|
|
tail -c "+$((offset + 1))" "$guest_log_snapshot" 2>/dev/null |
|
|
sed -u 's/^/[install.bat] /' || true
|
|
fi
|
|
|
|
printf '%s\n' "$size" > "$guest_log_offset"
|
|
fi
|
|
}
|
|
|
|
(
|
|
while true; do
|
|
print_guest_log
|
|
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
|
|
print_guest_log
|
|
}
|
|
|
|
trap stop_logs EXIT
|
|
|
|
failure_checks_disabled=0
|
|
callback_failure_detected=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 + INSTALL_TIMEOUT))
|
|
reboot_timeout="$REBOOT_TIMEOUT"
|
|
minimum_reboots="$MINIMUM_REBOOTS"
|
|
boot_loop_limit="$BOOT_LOOP_LIMIT"
|
|
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 (( callback_failure_detected == 0 )) &&
|
|
grep -Fq 'VALIDATION_CALLBACK_FAILED' <<< "$container_log"; then
|
|
callback_failure_detected=1
|
|
|
|
echo
|
|
echo "------------------------------------------------------------"
|
|
echo "The guest validation callback failed."
|
|
|
|
if grep -Fq 'VALIDATION_LOG_SYNC_FAILED' <<< "$container_log"; then
|
|
echo "The final install.log synchronization also failed."
|
|
fi
|
|
|
|
if [[ "$KILL_ON_FAILURE" == "true" ]]; then
|
|
echo "Stopping the Windows container..."
|
|
docker stop "$CONTAINER" > /dev/null
|
|
exit 1
|
|
fi
|
|
|
|
echo "Automatic termination is disabled by kill_on_failure."
|
|
echo "The container and noVNC tunnel will remain available."
|
|
echo "::warning title=Failure detected::The guest validation callback failed."
|
|
failure_checks_disabled=1
|
|
fi
|
|
|
|
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="$(
|
|
grep -F 'VALIDATION_RESULT=' <<< "$container_log" |
|
|
tail -n 1 |
|
|
sed 's/^.*VALIDATION_RESULT=//' |
|
|
tr -d '\r' || true
|
|
)"
|
|
|
|
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")"
|
|
oem_file="$(jq -r '.oem_file // empty' <<< "$response")"
|
|
share="$(jq -r '.share // empty' <<< "$response")"
|
|
share_write="$(jq -r '.share_write // empty' <<< "$response")"
|
|
internet="$(jq -r '.internet // empty' <<< "$response")"
|
|
|
|
if [[ "$token" != "$EXPECTED_TOKEN" ]]; then
|
|
echo "The response token does not match."
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$oem_file" != "$EXPECTED_TOKEN" ]]; then
|
|
echo "Failed to read C:\\OEM\\validation.token."
|
|
echo "Expected contents:"
|
|
echo " $EXPECTED_TOKEN"
|
|
echo "Received:"
|
|
echo " ${oem_file:-empty}"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$share" != "$EXPECTED_TOKEN" ]]; then
|
|
echo "Failed to read \\\\host.lan\\Data\\validation.token."
|
|
echo "Expected contents:"
|
|
echo " $EXPECTED_TOKEN"
|
|
echo "Received:"
|
|
echo " ${share:-empty}"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$share_write" != "ok" ]]; then
|
|
echo "The shared folder write test did not succeed."
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$internet" != "ok" ]]; then
|
|
echo "The guest internet connection test did not succeed."
|
|
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 "OEM files: copied successfully"
|
|
echo "Shared folder: readable and writable"
|
|
echo "Internet connection: 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
|