1
0
mirror of https://github.com/dockur/windows.git synced 2026-07-27 21:42:36 +07:00
Files
windows/.github/workflows/install.yml
T
2026-07-27 03:06:40 +02:00

893 lines
26 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: master
type: string
callback:
description: Guest script type
required: false
default: powershell
type: string
cpu:
description: CPU model exposed to Windows
required: false
default: ""
type: string
disk_type:
description: Disk controller type exposed to Windows
required: false
default: ""
type: string
kill_on_failure:
description: Stop the workflow when an installation failure is detected
required: false
default: false
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: Checkout branch
uses: actions/checkout@v7
with:
ref: ${{ inputs.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
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" <<EOF
@echo off
del /Q C:\OEM\install.done 2>nul
start "" /B cmd.exe /C C:\OEM\sync-log.bat
powershell.exe -NoProfile -ExecutionPolicy Bypass ^
-File C:\OEM\ready.ps1 ^
-Token "$token"
set "result=%errorlevel%"
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" <<EOF
@echo off
del /Q C:\OEM\install.done 2>nul
start "" /B cmd.exe /C C:\OEM\sync-log.bat
cscript.exe //B //NoLogo C:\OEM\ready.vbs "$token"
set "result=%errorlevel%"
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: ${{ inputs.branch }}"
echo "Commit: $(git rev-parse HEAD)"
for attempt in 1 2 3; do
if docker build \
--tag "$IMAGE" \
.; 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:
CPU: ${{ inputs.cpu }}
DISK_TYPE: ${{ inputs.disk_type }}
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=pc" \
--env "VERSION=$VERSION" \
--env "RAM_SIZE=half" \
--env "CPU_CORES=half" \
--env "DISK_SIZE=64G" \
--env "CPU_MODEL=$CPU" \
--env "DISK_TYPE=$DISK_TYPE" \
--env "LOG=Y" \
--env "DEBUG=Y" \
--volume "$RUNNER_TEMP/data:/shared" \
--volume "$RUNNER_TEMP/oem:/oem:ro" \
--volume "$RUNNER_TEMP/storage:/storage" \
"$IMAGE"
- name: Start noVNC 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 }}
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 (( failure_checks_disabled == 0 &&
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"
if ! handle_failure "Detected a repeated BIOS boot loop."; then
exit 1
fi
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