#Requires -RunAsAdministrator #Requires -Version 5.1 <# .SYNOPSIS Provisions a Windows VM as a CI build template (run INSIDE the VM once). .DESCRIPTION This script is run ONE TIME inside the template VM before taking the "BaseClean" snapshot. After the snapshot is taken, never modify the VM. Installs and configures (each step followed by Assert-Step validation): Step 1 — Firewall: all profiles disabled first — removes any block on subsequent steps Validation: Domain/Private/Public all Enabled=False Step 2 — Defender & Antimalware disabled — before WinRM and Windows Update Set-MpPreference + GPO registry keys; exclusions C:\CI, C:\dotnet, C:\Python Validation: 6 GPO keys + MpPreference (if WinDefend service present) Rationale: WU downloads + VS/Python/dotnet installers run unscanned; saves 5-15 min; WinRM traffic not inspected Step 3 — WinRM: Basic auth, AllowUnencrypted, MaxMemoryPerShellMB=2048, 2h timeout Firewall + Defender already off — no interference during config Validation: service Running+Automatic, AllowUnencrypted, Auth/Basic, memory Step 4 — Local build user account ($BuildUsername, PasswordNeverExpires, Administrators) Validation: user exists, enabled, PasswordNeverExpires, admin membership Step 4b — UAC disabled (EnableLUA=0, LocalAccountTokenFilterPolicy=1) Validation: both registry values verified Step 5 — CI working directories: C:\CI\build, C:\CI\output, C:\CI\scripts Must run before Windows Update — WU worker writes temp files to C:\CI Validation: each path exists as Container Step 5b — Explorer LaunchTo=1 (This PC) for current user + Default profile hive Validation: HKCU LaunchTo=1 Step 5c — CI UX hardening (all UI tweaks before the long-running WU step): Server Manager auto-start off (machine policy + HKCU Administrator + Default User hive → ci_build inherits at first logon) Ctrl+Alt+Del at login disabled (DisableCAD=1) — CI VMs never need it OOBE privacy/telemetry prompt suppressed (DisablePrivacyExperience=1, AllowTelemetry=0) — prevents first-logon interactive prompt in clones Validation: machine policy key, HKCU key, DisableCAD, OOBE policy, telemetry Step 6 — Windows Update via Scheduled Task as SYSTEM (avoids WinRM token limit) Runs with Firewall + Defender off, C:\CI present — faster, no scan overhead Validation: result code 0/2/3, no reboot required (else exit 3010) Step 6b — Windows Update permanently disabled (post-update lockdown) wuauserv + UsoSvc set to Disabled; GPO keys NoAutoUpdate=1, DisableWindowsUpdateAccess=1; WU scheduled tasks disabled Runs unconditionally (even with -SkipWindowsUpdate) so snapshot captures WU off — linked clones never trigger background updates Validation: wuauserv StartType=Disabled, GPO keys present Step 7 — .NET SDK (channel $DotNetSdkVersion) via dotnet-install.ps1 Validation: dotnet resolvable, version channel match, machine PATH Step 8 — Python $PythonVersion (all users, C:\Python, PrependPath) Validation: binary present, exact version match, PATH Step 9 — Visual Studio Build Tools 2026 via Scheduled Task as SYSTEM Validation: MSBuild.exe exists, executes, PATH, VC dir present Step 10 — Toolchain cross-check (dotnet, python, msbuild) Throws on any missing tool (was: warn-only) Cleanup — Disk cleanup: cleanmgr, SoftwareDistribution, CBS, TEMP, Prefetch, DISM wuauserv already Disabled (Step 6b) — not restarted after cache clear SoftwareDistribution\Download cleared best-effort (WaaSMedicSvc may repopulate) Validation: wuauserv StartType Disabled (SoftwareDistribution check removed — WaaSMedicSvc tamper-protection restores files immediately) Final — Pre-snapshot gate: 7 cross-cutting checks across all steps Throws if any check fails — prevents snapshot of broken state Step ordering rationale: Firewall first — removes any network block before WinRM config and WU downloads Defender second — eliminates scan overhead before WinRM, WU, and installer downloads WinRM third — configured clean with no Firewall/Defender interference User + UAC — quick registry ops; UAC off before dirs/tools avoids elevation prompts CI dirs — C:\CI must exist before WU worker writes temp files there UI tweaks — all fast registry writes batched before the slow WU step WU sixth — all prereqs (dirs, user, tweaks) done; Firewall+Defender off WU disable — immediately after WU completes; snapshot captures WU permanently off Toolchain — .NET / Python / VS last; WU done, no scan on installer payloads NOTE: Git is NOT installed. The host clones the repository and copies the source tree into the VM via WinRM (Option B isolation model). ╔══════════════════════════════════════════════════════════════════════╗ ║ NETWORK REQUIREMENT DURING SETUP ║ ║ The VM must have internet access while this script runs so it can ║ ║ download Windows Updates and tool installers. The VM NIC must be ║ ║ set to VMnet8 (NAT) — keep it on NAT permanently (builds use NAT ║ ║ for pip/nuget downloads at runtime). ║ ╚══════════════════════════════════════════════════════════════════════╝ After this script completes (exit 0, all Assert-Step checks passed): 1. Shut down the VM (Start → Shut down) The VM stays on VMnet8 (NAT) — internet access is required for builds. 2. In VMware Workstation: VM → Snapshot → Take Snapshot Name it exactly: BaseClean 3. Power off the VM and leave it powered off permanently .PARAMETER BuildUsername Local username for the CI build account. Default: ci_build. .PARAMETER BuildPassword Password for the build account. Mandatory — supplied by Prepare-WinBuild2025.ps1 (which prompts interactively). Store the same value in Windows Credential Manager on the HOST as target "BuildVMGuest". .PARAMETER DotNetSdkVersion .NET SDK channel to install. Default: 10.0 (matches host SDK). .PARAMETER PythonVersion Exact Python release to install (x.y.z, must exist on python.org). Default: 3.13.3. .PARAMETER SkipWindowsUpdate Skip the Windows Update step (useful if updates were already applied). .NOTES Invoke via Prepare-WinBuild2025.ps1 from the HOST (recommended), or run manually from an elevated PowerShell session INSIDE the VM: Set-ExecutionPolicy Bypass -Scope Process -Force .\Setup-WinBuild2025.ps1 -BuildPassword 'YourPassword' #> [CmdletBinding()] param( # Local username for the CI build account inside the VM [string] $BuildUsername = 'ci_build', # Password for the build account. # Must be supplied by the caller (Prepare-WinBuild2025.ps1 prompts interactively). # Store the same password in Windows Credential Manager on the HOST as target "BuildVMGuest". [Parameter(Mandatory)] [string] $BuildPassword, # .NET SDK channel to install (should match the host SDK version) [string] $DotNetSdkVersion = '10.0', # Python version to install (x.y.z — must match an exact release on python.org) [string] $PythonVersion = '3.13.3', # Skip Windows Update (useful when updates already applied or no internet) [switch] $SkipWindowsUpdate, # Skip disk cleanup step (cleanmgr + DISM + cache clear) — speeds up re-runs during dev/debug [switch] $SkipCleanup ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Write-Step { param([string]$Message) Write-Host "`n=== $Message ===" -ForegroundColor Cyan } function Assert-Step { param( [Parameter(Mandatory)] [string] $StepName, [Parameter(Mandatory)] [string] $Description, [Parameter(Mandatory)] [scriptblock] $Test ) $ok = $false try { $ok = [bool](& $Test) } catch { throw "[$StepName] Validation threw on '$Description': $_" } if (-not $ok) { throw "[$StepName] Validation failed: $Description" } Write-Host " [OK] $Description" -ForegroundColor Green } # ── Pre-flight: remove temp files from any previous partial run ─────────────── # These files are normally deleted at the end of each step that creates them. # A previous interrupted run may have left them behind — remove before starting. $knownTempFiles = @( 'C:\CI\wu_worker.ps1', 'C:\CI\wu_result.txt', 'C:\CI\wu_reboot.txt', 'C:\CI\wu_log.txt', 'C:\CI\vs_worker.ps1', 'C:\CI\vs_result.txt', 'C:\CI\vs_BuildTools.exe', 'C:\CI\python_installer.exe' ) foreach ($f in $knownTempFiles) { if (Test-Path $f) { Remove-Item $f -Force -ErrorAction SilentlyContinue Write-Host "[PreFlight] Removed leftover temp file: $f" } } # ── Step 1: Firewall ────────────────────────────────────────────────────────── Write-Step "Disabling Windows Firewall (all profiles) — isolated lab VM" # Disable first: removes any block on subsequent WinRM config and WU downloads. # This VM lives on VMnet8 NAT behind the VMware NAT gateway — no inbound exposure # from outside the host, so full disable is acceptable. Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False Write-Host "Windows Firewall disabled on all profiles." # Validation foreach ($p in 'Domain','Private','Public') { Assert-Step 'Firewall' "$p profile disabled" { (Get-NetFirewallProfile -Profile $p -ErrorAction Stop).Enabled -eq $false } } # ── Step 2: Disable Windows Defender & Antimalware ─────────────────────────── Write-Step "Disabling Windows Defender & Antimalware" # Disabled before WinRM config and Windows Update so that: # - WU downloads/installs are not scanned (saves 5-15 min on update-heavy runs) # - WinRM traffic is not intercepted by network inspection # On an isolated CI template VM this trade-off is acceptable. $defSvc = Get-Service -Name WinDefend -ErrorAction SilentlyContinue if ($defSvc) { Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction SilentlyContinue 3>$null Set-MpPreference -DisableBehaviorMonitoring $true -ErrorAction SilentlyContinue 3>$null Set-MpPreference -DisableIOAVProtection $true -ErrorAction SilentlyContinue 3>$null Set-MpPreference -DisableScriptScanning $true -ErrorAction SilentlyContinue 3>$null # Exclude CI build paths from any remaining scanning Add-MpPreference -ExclusionPath 'C:\CI' -ErrorAction SilentlyContinue 3>$null Add-MpPreference -ExclusionPath 'C:\dotnet' -ErrorAction SilentlyContinue 3>$null Add-MpPreference -ExclusionPath 'C:\Python' -ErrorAction SilentlyContinue 3>$null Write-Host "Windows Defender real-time protection disabled." } else { Write-Host "Windows Defender service not found — skipping." } # Disable via Group Policy registry key — survives Defender auto-updates and reboots. # This is the same key that SCCM/GPO uses in enterprise environments. $defenderKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' if (-not (Test-Path $defenderKey)) { New-Item -Path $defenderKey -Force | Out-Null } Set-ItemProperty -Path $defenderKey -Name 'DisableAntiSpyware' -Value 1 -Type DWord -Force Set-ItemProperty -Path $defenderKey -Name 'DisableAntiVirus' -Value 1 -Type DWord -Force # Also disable via the Windows Security Center policy subkey $rtKey = "$defenderKey\Real-Time Protection" if (-not (Test-Path $rtKey)) { New-Item -Path $rtKey -Force | Out-Null } Set-ItemProperty -Path $rtKey -Name 'DisableRealtimeMonitoring' -Value 1 -Type DWord -Force Set-ItemProperty -Path $rtKey -Name 'DisableBehaviorMonitoring' -Value 1 -Type DWord -Force Set-ItemProperty -Path $rtKey -Name 'DisableIOAVProtection' -Value 1 -Type DWord -Force Set-ItemProperty -Path $rtKey -Name 'DisableScriptScanning' -Value 1 -Type DWord -Force Write-Host "Defender GPO registry keys written (persistent across updates)." # Validation — GPO registry keys (always applicable, survives Defender presence) Assert-Step 'Defender' "GPO DisableAntiSpyware=1" { (Get-ItemProperty -Path $defenderKey -Name DisableAntiSpyware -ErrorAction Stop).DisableAntiSpyware -eq 1 } Assert-Step 'Defender' "GPO DisableAntiVirus=1" { (Get-ItemProperty -Path $defenderKey -Name DisableAntiVirus -ErrorAction Stop).DisableAntiVirus -eq 1 } foreach ($rtName in 'DisableRealtimeMonitoring','DisableBehaviorMonitoring','DisableIOAVProtection','DisableScriptScanning') { Assert-Step 'Defender' "GPO Real-Time Protection $rtName=1" { (Get-ItemProperty -Path $rtKey -Name $rtName -ErrorAction Stop).$rtName -eq 1 } } # Validation — Defender preferences (only if service exists) if ($defSvc) { $mp = Get-MpPreference -ErrorAction SilentlyContinue if ($mp) { Assert-Step 'Defender' 'Set-MpPreference DisableRealtimeMonitoring=true' { $mp.DisableRealtimeMonitoring } Assert-Step 'Defender' "ExclusionPath contains 'C:\CI'" { $mp.ExclusionPath -contains 'C:\CI' } } } # ── Step 3: WinRM ───────────────────────────────────────────────────────────── Write-Step "Configuring WinRM" # Firewall and Defender already off — no interference during WinRM hardening. Enable-PSRemoting -Force -SkipNetworkProfileCheck 3>$null | Out-Null # Allow unencrypted (HTTP/5985) — acceptable for an isolated lab network. # Migrate to HTTPS/5986 with a self-signed cert for hardened environments. winrm set winrm/config/service '@{AllowUnencrypted="true"}' | Out-Null winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null # Increase shell memory and timeout limits for long builds winrm set winrm/config/winrs '@{MaxProcessesPerShell="25"}' | Out-Null Set-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB 2048 -Force 3>$null Set-Item WSMan:\localhost\Shell\IdleTimeOut 7200000 -Force 3>$null # 2 hours # Ensure WinRM starts automatically Set-Service -Name WinRM -StartupType Automatic 3>$null Start-Service WinRM 3>$null Write-Host "WinRM configured." # Validation Assert-Step 'WinRM' 'service Running' { (Get-Service WinRM -ErrorAction Stop).Status -eq 'Running' } Assert-Step 'WinRM' 'service StartType Automatic' { (Get-Service WinRM -ErrorAction Stop).StartType -eq 'Automatic' } Assert-Step 'WinRM' 'AllowUnencrypted=true' { (Get-Item WSMan:\localhost\Service\AllowUnencrypted).Value -eq 'true' } Assert-Step 'WinRM' 'Auth/Basic=true' { (Get-Item WSMan:\localhost\Service\Auth\Basic).Value -eq 'true' } Assert-Step 'WinRM' 'MaxMemoryPerShellMB >= 2048' { [int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB).Value -ge 2048 } # ── Step 4: Build user account ─────────────────────────────────────────────── Write-Step "Creating build user account: $BuildUsername" $existingUser = Get-LocalUser -Name $BuildUsername -ErrorAction SilentlyContinue if ($existingUser) { Write-Host "User '$BuildUsername' already exists — skipping creation." } else { # Convert to SecureString locally (inside the VM session) to avoid exposing # the password as a process argument visible in audit log event 4688. $secPwd = ConvertTo-SecureString $BuildPassword -AsPlainText -Force try { New-LocalUser -Name $BuildUsername -Password $secPwd ` -PasswordNeverExpires -Description 'CI build account' ` -ErrorAction Stop | Out-Null } catch { throw "Failed to create user '$BuildUsername': $_" } Write-Host "User '$BuildUsername' created." } # Always ensure password policy and Administrators membership (idempotent) & net user $BuildUsername /passwordchg:no | Out-Null Set-LocalUser -Name $BuildUsername -PasswordNeverExpires $true $isMember = & net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername if (-not $isMember) { & net localgroup Administrators $BuildUsername /add | Out-Null Write-Host "User '$BuildUsername' added to Administrators." } else { Write-Host "User '$BuildUsername' already in Administrators." } # Validation Assert-Step 'User' "user '$BuildUsername' exists" { [bool](Get-LocalUser -Name $BuildUsername -ErrorAction SilentlyContinue) } Assert-Step 'User' "user '$BuildUsername' enabled" { (Get-LocalUser -Name $BuildUsername -ErrorAction Stop).Enabled } Assert-Step 'User' "user '$BuildUsername' password never expires" { # Property name varies across LocalAccounts module versions: # - Newer (Server 2022+, some SKUs): bool 'PasswordNeverExpires' # - Older / WS2025 PS 5.1: nullable datetime 'PasswordExpires' ($null = never) $u = Get-LocalUser -Name $BuildUsername -ErrorAction Stop $hasFlag = $u.PSObject.Properties.Name -contains 'PasswordNeverExpires' if ($hasFlag) { [bool]$u.PasswordNeverExpires } else { $null -eq $u.PasswordExpires } } Assert-Step 'User' "user '$BuildUsername' member of Administrators" { [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername) } # ── Step 4b: Disable UAC ───────────────────────────────────────────────────── Write-Step "Disabling UAC (isolated lab VM)" # EnableLUA=0 disables UAC entirely — admin processes get full token without prompt. # LocalAccountTokenFilterPolicy=1 ensures remote admin connections (WinRM) also # get an elevated token (avoids the filtered-token issue with runProgramInGuest). Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' ` -Name EnableLUA -Value 0 -Type DWord -Force Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' ` -Name LocalAccountTokenFilterPolicy -Value 1 -Type DWord -Force Write-Host "UAC disabled." # Validation $uacPolicyKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' Assert-Step 'UAC' 'EnableLUA=0' { (Get-ItemProperty -Path $uacPolicyKey -Name EnableLUA -ErrorAction Stop).EnableLUA -eq 0 } Assert-Step 'UAC' 'LocalAccountTokenFilterPolicy=1' { (Get-ItemProperty -Path $uacPolicyKey -Name LocalAccountTokenFilterPolicy -ErrorAction Stop).LocalAccountTokenFilterPolicy -eq 1 } # ── Step 5: CI working directories ─────────────────────────────────────────── Write-Step "Creating CI working directories" # Must run before Windows Update (Step 6): the WU worker script writes temp files # to C:\CI\ (wu_worker.ps1, wu_result.txt, wu_reboot.txt, wu_log.txt). $ciDirs = @('C:\CI\build', 'C:\CI\output', 'C:\CI\scripts') foreach ($dir in $ciDirs) { if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null Write-Host "Created: $dir" } } # Validation foreach ($dir in $ciDirs) { Assert-Step 'CIDirs' "$dir exists (Container)" { Test-Path -Path $dir -PathType Container } } # ── Step 5b: Explorer settings ─────────────────────────────────────────────── Write-Step "Configuring Explorer defaults" # Open Explorer to 'This PC' (Devices and Drives) instead of Quick Access. # LaunchTo=1 = This PC, LaunchTo=2 = Quick Access (default) $explorerAdvKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' Set-ItemProperty -Path $explorerAdvKey -Name 'LaunchTo' -Value 1 -Type DWord -Force # Also apply to the Default User profile so ci_build and any new user inherits it. $defaultHive = 'C:\Users\Default\NTUSER.DAT' $mountName = 'TempDefaultUser' if (Test-Path $defaultHive) { reg load "HKU\$mountName" $defaultHive | Out-Null $defKey = "Registry::HKU\$mountName\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced" if (-not (Test-Path $defKey)) { New-Item -Path $defKey -Force | Out-Null } Set-ItemProperty -Path $defKey -Name 'LaunchTo' -Value 1 -Type DWord -Force [gc]::Collect() reg unload "HKU\$mountName" | Out-Null Write-Host "Explorer: 'This PC' set for current user and default profile." } else { Write-Host "Explorer: 'This PC' set for current user (default hive not found)." } # Validation Assert-Step 'Explorer' "LaunchTo=1 (HKCU current user)" { (Get-ItemProperty -Path $explorerAdvKey -Name LaunchTo -ErrorAction Stop).LaunchTo -eq 1 } # ── Step 5c: CI UX hardening ───────────────────────────────────────────────── Write-Step "CI UX hardening (lock screen off, Server Manager off, no CAD, no OOBE)" # All UI tweaks batched here — fast registry writes before the slow WU step. # 1 — Lock screen: disable entirely (CI VMs never need a lock screen) # NoLockScreen=1 suppresses the lock screen shown before login on console sessions. $personPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' if (-not (Test-Path $personPolicyKey)) { New-Item -Path $personPolicyKey -Force | Out-Null } Set-ItemProperty -Path $personPolicyKey -Name 'NoLockScreen' -Value 1 -Type DWord -Force Write-Host "Lock screen disabled." # 2 — Server Manager: do not auto-open at logon # Three layers needed on WS2025: # a) Machine policy key (GPO path — read by ServerManager policy check) # b) HKLM non-policy key (read directly by ServerManager.exe on WS2025) # c) Scheduled Task '\Microsoft\Windows\Server Manager\ServerManager' # — this is the actual launcher; registry alone is insufficient on WS2025 # d) HKCU + Default hive for per-user belt-and-suspenders $smPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' if (-not (Test-Path $smPolicyKey)) { New-Item -Path $smPolicyKey -Force | Out-Null } Set-ItemProperty -Path $smPolicyKey -Name 'DoNotOpenAtLogon' -Value 1 -Type DWord -Force $smHKLMKey = 'HKLM:\SOFTWARE\Microsoft\ServerManager' if (-not (Test-Path $smHKLMKey)) { New-Item -Path $smHKLMKey -Force | Out-Null } Set-ItemProperty -Path $smHKLMKey -Name 'DoNotOpenServerManagerAtLogon' -Value 1 -Type DWord -Force Disable-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' ` -TaskName 'ServerManager' -ErrorAction SilentlyContinue | Out-Null # Current session user (Administrator) $smUserKey = 'HKCU:\SOFTWARE\Microsoft\ServerManager' if (-not (Test-Path $smUserKey)) { New-Item -Path $smUserKey -Force | Out-Null } Set-ItemProperty -Path $smUserKey -Name 'DoNotOpenServerManagerAtLogon' -Value 1 -Type DWord -Force # Default User hive — ci_build and any future account inherits at first logon $smDefaultHive = 'C:\Users\Default\NTUSER.DAT' $smMountName = 'TempDefaultUserSM' if (Test-Path $smDefaultHive) { reg load "HKU\$smMountName" $smDefaultHive | Out-Null $smDefKey = "Registry::HKU\$smMountName\SOFTWARE\Microsoft\ServerManager" if (-not (Test-Path $smDefKey)) { New-Item -Path $smDefKey -Force | Out-Null } Set-ItemProperty -Path $smDefKey -Name 'DoNotOpenServerManagerAtLogon' -Value 1 -Type DWord -Force [gc]::Collect() reg unload "HKU\$smMountName" | Out-Null Write-Host "Server Manager: policy + HKLM + task + Administrator HKCU + Default hive set." } else { Write-Host "Server Manager: policy + HKLM + task + Administrator HKCU set (Default hive not found)." } # 3 — Disable Ctrl+Alt+Del at interactive login # Set in both locations: Winlogon (classic) + Policies\System (policy, authoritative on WS2025). Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' ` -Name 'DisableCAD' -Value 1 -Type DWord -Force $systemPolicyKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' Set-ItemProperty -Path $systemPolicyKey -Name 'DisableCAD' -Value 1 -Type DWord -Force Write-Host "Ctrl+Alt+Del at login disabled (Winlogon + Policies\System)." # 4 — Suppress OOBE privacy/telemetry prompt $oobePolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' if (-not (Test-Path $oobePolicyKey)) { New-Item -Path $oobePolicyKey -Force | Out-Null } Set-ItemProperty -Path $oobePolicyKey -Name 'DisablePrivacyExperience' -Value 1 -Type DWord -Force $oobeKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE' if (-not (Test-Path $oobeKey)) { New-Item -Path $oobeKey -Force | Out-Null } Set-ItemProperty -Path $oobeKey -Name 'DisablePrivacyExperience' -Value 1 -Type DWord -Force $dcKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' if (-not (Test-Path $dcKey)) { New-Item -Path $dcKey -Force | Out-Null } Set-ItemProperty -Path $dcKey -Name 'AllowTelemetry' -Value 0 -Type DWord -Force Write-Host "OOBE privacy prompt and telemetry disabled." # Validation Assert-Step 'CIUX' 'lock screen disabled (NoLockScreen=1)' { (Get-ItemProperty -Path $personPolicyKey -Name NoLockScreen -ErrorAction Stop).NoLockScreen -eq 1 } Assert-Step 'CIUX' 'Server Manager policy DoNotOpenAtLogon=1' { (Get-ItemProperty -Path $smPolicyKey -Name DoNotOpenAtLogon -ErrorAction Stop).DoNotOpenAtLogon -eq 1 } Assert-Step 'CIUX' 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' { (Get-ItemProperty -Path $smHKLMKey -Name DoNotOpenServerManagerAtLogon -ErrorAction Stop).DoNotOpenServerManagerAtLogon -eq 1 } Assert-Step 'CIUX' 'Server Manager scheduled task disabled' { $t = Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' ` -TaskName 'ServerManager' -ErrorAction SilentlyContinue (-not $t) -or ($t.State -eq 'Disabled') } Assert-Step 'CIUX' 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1 (Administrator)' { (Get-ItemProperty -Path $smUserKey -Name DoNotOpenServerManagerAtLogon -ErrorAction Stop).DoNotOpenServerManagerAtLogon -eq 1 } Assert-Step 'CIUX' 'DisableCAD=1 in Policies\System' { (Get-ItemProperty -Path $systemPolicyKey -Name DisableCAD -ErrorAction Stop).DisableCAD -eq 1 } Assert-Step 'CIUX' 'OOBE policy DisablePrivacyExperience=1' { (Get-ItemProperty -Path $oobePolicyKey -Name DisablePrivacyExperience -ErrorAction Stop).DisablePrivacyExperience -eq 1 } Assert-Step 'CIUX' 'DataCollection AllowTelemetry=0' { (Get-ItemProperty -Path $dcKey -Name AllowTelemetry -ErrorAction Stop).AllowTelemetry -eq 0 } # ── Step 6: Windows Update ──────────────────────────────────────────────────── # Firewall + Defender already off: WU downloads and installs run without scanning. # C:\CI exists (Step 5): worker temp files write successfully. # NOTE: The Windows Update COM API (Microsoft.Update.Session) raises E_ACCESSDENIED # when called from a WinRM/network-logon session. We work around this by running # the actual download+install inside a Scheduled Task (SYSTEM account, full token). if ($SkipWindowsUpdate) { Write-Host "[Setup] Skipping Windows Update (SkipWindowsUpdate switch set)." } else { Write-Step "Running Windows Update via Scheduled Task (avoids WinRM token restrictions)" # --- worker script that runs as SYSTEM inside the scheduled task --- $wuWorkerPath = 'C:\CI\wu_worker.ps1' $wuResultPath = 'C:\CI\wu_result.txt' $wuRebootPath = 'C:\CI\wu_reboot.txt' $wuLogPath = 'C:\CI\wu_log.txt' $wuWorker = @' $ErrorActionPreference = 'Stop' try { $s = New-Object -ComObject Microsoft.Update.Session $q = $s.CreateUpdateSearcher() $q.Online = $true # force live query — skip stale client cache $q.ServerSelection = 2 # 2=ssWindowsUpdate — bypass WSUS, query WU servers directly $found = $q.Search("IsInstalled=0 and Type='Software'") $count = $found.Updates.Count if ($count -eq 0) { "0" | Set-Content C:\CI\wu_result.txt "False" | Set-Content C:\CI\wu_reboot.txt "No updates needed (0 updates found by COM API)." | Set-Content C:\CI\wu_log.txt } else { $names = ($found.Updates | ForEach-Object { " - $($_.Title)" }) -join "`n" "Found $count update(s):`n$names" | Set-Content C:\CI\wu_log.txt "Downloading $count update(s)..." | Add-Content C:\CI\wu_log.txt $dl = $s.CreateUpdateDownloader() $dl.Updates = $found.Updates $dl.Download() | Out-Null "Installing $count update(s)..." | Add-Content C:\CI\wu_log.txt $inst = $s.CreateUpdateInstaller() $inst.Updates = $found.Updates $r = $inst.Install() [string]$r.ResultCode | Set-Content C:\CI\wu_result.txt [string]$r.RebootRequired | Set-Content C:\CI\wu_reboot.txt "Done. ResultCode=$($r.ResultCode) RebootRequired=$($r.RebootRequired)" | Add-Content C:\CI\wu_log.txt } } catch { "ERROR: $_" | Set-Content C:\CI\wu_log.txt "99" | Set-Content C:\CI\wu_result.txt "False" | Set-Content C:\CI\wu_reboot.txt } '@ $wuWorker | Set-Content -Path $wuWorkerPath -Encoding UTF8 # Re-enable WU fully before launching the COM API worker. # A previous run of this script (Step 6b) leaves: wuauserv=Disabled, UsoSvc=Disabled, # DisableWindowsUpdateAccess=1, stale DataStore metadata. # Microsoft.Update.Session.Search() silently returns 0 results if any of these are set. $wuPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' $wuPolicyAUKey = "$wuPolicyKey\AU" # Step A: clear GPO policy blocks Remove-ItemProperty -Path $wuPolicyKey -Name 'DisableWindowsUpdateAccess' -ErrorAction SilentlyContinue Remove-ItemProperty -Path $wuPolicyAUKey -Name 'NoAutoUpdate' -ErrorAction SilentlyContinue Remove-ItemProperty -Path $wuPolicyAUKey -Name 'AUOptions' -ErrorAction SilentlyContinue # Step B: stop WU-adjacent services cleanly before resetting state foreach ($svc in 'wuauserv', 'UsoSvc', 'bits') { Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue } # Step C: delete WU client database — forces fresh online detection on next start. # Without this, the COM API reuses stale cached metadata from the previous run # and reports 0 available updates even when updates exist. Remove-Item 'C:\Windows\SoftwareDistribution\DataStore\*' -Recurse -Force -ErrorAction SilentlyContinue # Step D: re-enable and start all services the COM API depends on foreach ($svc in 'cryptsvc', 'bits', 'wuauserv', 'UsoSvc') { Set-Service -Name $svc -StartupType Manual -ErrorAction SilentlyContinue Start-Service -Name $svc -ErrorAction SilentlyContinue } # Step E: wait for WU service to fully initialize and build fresh DataStore Start-Sleep -Seconds 20 Write-Host "Windows Update services started. DataStore reset. GPO blocks cleared." # Remove any leftover result files from a previous run Remove-Item $wuResultPath, $wuRebootPath -ErrorAction SilentlyContinue # Register scheduled task running as SYSTEM $taskName = 'CI-WindowsUpdate' Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` -Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$wuWorkerPath`"" $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null Write-Host "Scheduled task registered. Starting Windows Update..." Start-ScheduledTask -TaskName $taskName # Poll until the task finishes (result file written = task complete) $timeout = [datetime]::UtcNow.AddMinutes(60) $dotCount = 0 while (-not (Test-Path $wuResultPath)) { if ([datetime]::UtcNow -gt $timeout) { throw "Windows Update timed out after 60 minutes." } Start-Sleep -Seconds 15 $dotCount++ if ($dotCount % 4 -eq 0) { Write-Host " ... still running ($(([datetime]::UtcNow - ($timeout.AddMinutes(-60))).Minutes) min elapsed)" } } # Read results $resultCode = [int](Get-Content $wuResultPath -Raw).Trim() $rebootNeeded = (Get-Content $wuRebootPath -Raw).Trim() -eq 'True' $log = Get-Content $wuLogPath -Raw -ErrorAction SilentlyContinue Write-Host "Windows Update log:" Write-Host $log # Clean up Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue Remove-Item $wuWorkerPath, $wuResultPath, $wuRebootPath, $wuLogPath -ErrorAction SilentlyContinue if ($resultCode -eq 99) { throw "Windows Update worker script failed. See log above." } if ($resultCode -notin @(0, 2, 3)) { Write-Warning "Windows Update returned unexpected result code: $resultCode" } else { Write-Host "Windows Update completed (ResultCode=$resultCode)." } if ($rebootNeeded) { Write-Warning "*** REBOOT REQUIRED to finish installing updates. ***" Write-Warning " Restart the VM, then re-run this script with: -SkipWindowsUpdate" exit 3010 } } # ── Step 6b: Disable Windows Update permanently (post-update lockdown) ─────── Write-Step "Disabling Windows Update permanently (post-update snapshot lockdown)" # WU has completed (or was skipped via -SkipWindowsUpdate). Disable now so that # every linked clone spawned from the BaseClean snapshot never triggers background # updates, spurious reboots, or download lock contention on the shared NVMe. # Stop + disable main WU service Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue Set-Service -Name wuauserv -StartupType Disabled # Stop + disable Update Orchestrator Service (UsoSvc) — WU client in Server 2025 Stop-Service -Name UsoSvc -Force -ErrorAction SilentlyContinue Set-Service -Name UsoSvc -StartupType Disabled -ErrorAction SilentlyContinue Write-Host "wuauserv and UsoSvc set to Disabled." # GPO registry keys — survive service self-healing and future WU self-repair attempts $wuKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' $wuAUKey = "$wuKey\AU" if (-not (Test-Path $wuKey)) { New-Item -Path $wuKey -Force | Out-Null } if (-not (Test-Path $wuAUKey)) { New-Item -Path $wuAUKey -Force | Out-Null } # Block WU API + UI access entirely Set-ItemProperty -Path $wuKey -Name 'DisableWindowsUpdateAccess' -Value 1 -Type DWord -Force # AutoUpdate policy: AUOptions=1 = disabled; NoAutoUpdate=1 = no background check Set-ItemProperty -Path $wuAUKey -Name 'NoAutoUpdate' -Value 1 -Type DWord -Force Set-ItemProperty -Path $wuAUKey -Name 'AUOptions' -Value 1 -Type DWord -Force Write-Host "Windows Update GPO policy keys written." # Disable WU scheduled tasks (best-effort — some may not exist on every SKU) $wuTaskPath = '\Microsoft\Windows\WindowsUpdate\' foreach ($taskName in @('Scheduled Start', 'WakeTimer', 'Automatic App Update', 'sih', 'sihboot', 'USO_UxBroker')) { Disable-ScheduledTask -TaskPath $wuTaskPath -TaskName $taskName ` -ErrorAction SilentlyContinue | Out-Null } Write-Host "Windows Update scheduled tasks disabled (best-effort)." # Validation Assert-Step 'WUDisable' 'wuauserv StartType Disabled' { (Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled' } Assert-Step 'WUDisable' 'NoAutoUpdate=1 GPO key' { (Get-ItemProperty -Path $wuAUKey -Name NoAutoUpdate -ErrorAction Stop).NoAutoUpdate -eq 1 } Assert-Step 'WUDisable' 'DisableWindowsUpdateAccess=1 GPO key' { (Get-ItemProperty -Path $wuKey -Name DisableWindowsUpdateAccess -ErrorAction Stop).DisableWindowsUpdateAccess -eq 1 } # ── Step 7: Install .NET SDK ────────────────────────────────────────────────── Write-Step "Installing .NET SDK $DotNetSdkVersion" $dotnetInstalled = Get-Command dotnet -ErrorAction SilentlyContinue if ($dotnetInstalled) { $installedVersion = dotnet --version Write-Host ".NET SDK already installed: $installedVersion" } else { Write-Host "Downloading dotnet-install.ps1..." $dotnetInstallScript = "$env:TEMP\dotnet-install.ps1" Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' ` -OutFile $dotnetInstallScript -UseBasicParsing Write-Host "Installing .NET SDK $DotNetSdkVersion (channel)..." & $dotnetInstallScript ` -Channel $DotNetSdkVersion ` -InstallDir 'C:\dotnet' ` -NoPath # Add to system PATH $currentPath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') if ($currentPath -notlike '*C:\dotnet*') { [System.Environment]::SetEnvironmentVariable( 'PATH', "$currentPath;C:\dotnet", 'Machine' ) } $env:PATH = $env:PATH + ';C:\dotnet' Write-Host ".NET SDK installed: $(dotnet --version)" } # Validation Assert-Step 'DotNet' 'dotnet command resolvable' { [bool](Get-Command dotnet -ErrorAction SilentlyContinue) } Assert-Step 'DotNet' "SDK version starts with channel '$DotNetSdkVersion'" { $v = (& dotnet --version 2>&1 | Select-Object -First 1).ToString().Trim() $major = $DotNetSdkVersion.Split('.')[0] $v.StartsWith("$major.") } Assert-Step 'DotNet' 'machine PATH contains C:\dotnet' { [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') -like '*C:\dotnet*' } # ── Step 8: Install Python ─────────────────────────────────────────────────── Write-Step "Installing Python $PythonVersion" $pythonExe = 'C:\Python\python.exe' if (Test-Path $pythonExe) { Write-Host "Python already installed: $(& $pythonExe --version 2>&1)" } else { $pyInstallerUrl = "https://www.python.org/ftp/python/$PythonVersion/python-$PythonVersion-amd64.exe" $pyInstallerPath = 'C:\CI\python_installer.exe' Write-Host "Downloading Python $PythonVersion installer..." Invoke-WebRequest -Uri $pyInstallerUrl -OutFile $pyInstallerPath -UseBasicParsing Write-Host "Installing Python $PythonVersion (all users, prepend PATH)..." $pyProc = Start-Process -FilePath $pyInstallerPath -ArgumentList @( '/quiet', 'InstallAllUsers=1', 'PrependPath=1', 'Include_test=0', 'Include_doc=0', 'TargetDir=C:\Python' ) -Wait -PassThru Remove-Item $pyInstallerPath -ErrorAction SilentlyContinue if ($pyProc -and $pyProc.ExitCode -ne 0) { throw "Python installation failed (exit $($pyProc.ExitCode))." } # Refresh PATH for subsequent steps $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User') Write-Host "Python installed: $(python --version 2>&1)" } # Validation Assert-Step 'Python' 'C:\Python\python.exe present' { Test-Path 'C:\Python\python.exe' -PathType Leaf } Assert-Step 'Python' "version equals $PythonVersion" { $v = (& 'C:\Python\python.exe' --version 2>&1 | Select-Object -First 1).ToString().Trim() $v -match [regex]::Escape($PythonVersion) } Assert-Step 'Python' 'PATH contains C:\Python (machine or user)' { $combined = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User') $combined -like '*C:\Python*' } # ── Step 9: Install Visual Studio Build Tools 2026 ─────────────────────────── Write-Step "Installing Visual Studio Build Tools 2026" $msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe' if (Test-Path $msbuildPath) { Write-Host "VS Build Tools 2026 already installed." } else { $vsInstallerUrl = 'https://aka.ms/vs/stable/vs_buildtools.exe' $vsInstallerPath = 'C:\CI\vs_BuildTools.exe' $vsResultPath = 'C:\CI\vs_result.txt' $vsLogPath = 'C:\CI\vs_log.txt' Write-Host "Downloading VS Build Tools installer..." Remove-Item $vsInstallerPath -ErrorAction SilentlyContinue # remove any stale/corrupt copy Invoke-WebRequest -Uri $vsInstallerUrl -OutFile $vsInstallerPath -UseBasicParsing # Remove Zone.Identifier ADS — files downloaded from internet are marked Zone 3 (Internet). # SYSTEM account cannot execute them without this unblock step. Unblock-File -Path $vsInstallerPath -ErrorAction SilentlyContinue # Verify the download produced a valid Windows PE file (MZ header) $mzBytes = [System.IO.File]::ReadAllBytes($vsInstallerPath) | Select-Object -First 2 if (-not ($mzBytes[0] -eq 0x4D -and $mzBytes[1] -eq 0x5A)) { $fileSize = (Get-Item $vsInstallerPath).Length throw "VS installer download appears invalid (size=$fileSize, not an EXE). Check the URL: $vsInstallerUrl" } Write-Host "Installer verified OK ($((Get-Item $vsInstallerPath).Length) bytes)." # VS installer fails under WinRM (network logon token). Run via Scheduled Task as SYSTEM. $vsWorkerPath = 'C:\CI\vs_worker.ps1' $vsWorker = @" # SYSTEM account may have no TEMP set — use C:\Windows\Temp `$env:TEMP = 'C:\Windows\Temp' `$env:TMP = 'C:\Windows\Temp' # Clean any corrupt VS installer cache left by previous failed attempts Remove-Item 'C:\ProgramData\Microsoft\VisualStudio\Packages' -Recurse -Force -ErrorAction SilentlyContinue Remove-Item 'C:\Windows\Temp\vs*' -Recurse -Force -ErrorAction SilentlyContinue Remove-Item 'C:\Windows\Temp\dd_*' -Force -ErrorAction SilentlyContinue `$result = try { `$proc = Start-Process -FilePath '$vsInstallerPath' -ArgumentList @( '--quiet','--wait','--norestart','--nocache', '--installPath "C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools"', '--add','Microsoft.VisualStudio.Workload.VCTools', '--includeRecommended', '--add','Microsoft.VisualStudio.Workload.MSBuildTools', '--add','Microsoft.VisualStudio.Workload.ManagedDesktopBuildTools', '--add','Microsoft.VisualStudio.Component.NuGet.BuildTools', '--add','Microsoft.Net.Component.4.8.SDK', '--add','Microsoft.Net.Component.4.7.2.TargetingPack' ) -Wait -PassThru if (`$proc) { [string]`$proc.ExitCode } else { '99' } } catch { "99: `$_" } if (-not `$result) { `$result = '99' } `$result | Set-Content '$vsResultPath' -Encoding UTF8 "@ $vsWorker | Set-Content -Path $vsWorkerPath -Encoding UTF8 Remove-Item $vsResultPath -ErrorAction SilentlyContinue $taskName = 'CI-VSBuildTools' Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` -Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$vsWorkerPath`"" $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null Write-Host "Installing VS Build Tools via Scheduled Task (this may take 15-30 minutes)..." Start-ScheduledTask -TaskName $taskName $timeout = [datetime]::UtcNow.AddMinutes(60) $dots = 0 while (-not (Test-Path $vsResultPath)) { if ([datetime]::UtcNow -gt $timeout) { throw "VS Build Tools installation timed out." } # If task already finished without writing the result file, the worker itself crashed $taskInfo = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue if ($taskInfo -and $taskInfo.State -eq 'Ready') { $lastResult = (Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue).LastTaskResult throw "VS worker task exited prematurely (LastTaskResult=0x$('{0:X}' -f $lastResult)). Check C:\CI\vs_worker.ps1 for syntax errors." } Start-Sleep -Seconds 20 $dots++ if ($dots % 3 -eq 0) { Write-Host " ... still installing ($(([datetime]::UtcNow - ($timeout.AddMinutes(-60))).Minutes) min elapsed)" } } $rawResult = Get-Content $vsResultPath -Raw -ErrorAction SilentlyContinue $rawResult = if ($rawResult) { $rawResult.Trim() } else { '99' } # Result file may contain "0", "3010", or "99: " $vsExit = if ($rawResult -match '^\d+$') { [int]$rawResult } else { 99 } $vsMsg = if ($vsExit -eq 99) { $rawResult } else { '' } Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue Remove-Item $vsWorkerPath, $vsResultPath, $vsInstallerPath -ErrorAction SilentlyContinue if ($vsExit -notin @(0, 3010)) { throw "VS Build Tools installation failed (exit $vsExit). $vsMsg" } Write-Host "VS Build Tools 2026 installed (exit code $vsExit)." } # Add MSBuild to system PATH $msbuildDir = Split-Path $msbuildPath -Parent $currentPath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') if ($currentPath -notlike "*$msbuildDir*") { [System.Environment]::SetEnvironmentVariable( 'PATH', "$currentPath;$msbuildDir", 'Machine' ) Write-Host "MSBuild added to system PATH." } # Validation Assert-Step 'VSBuildTools' "MSBuild.exe exists at expected path" { Test-Path -Path $msbuildPath -PathType Leaf } Assert-Step 'VSBuildTools' 'MSBuild executes and reports version' { $out = & $msbuildPath -version 2>&1 | Select-Object -Last 1 $out -match '^\d+\.\d+' } Assert-Step 'VSBuildTools' 'machine PATH contains MSBuild dir' { [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') -like "*$msbuildDir*" } Assert-Step 'VSBuildTools' 'VC tools install dir present' { Test-Path 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\VC' -PathType Container } # ── Step 10: Verify toolchain ───────────────────────────────────────────────── Write-Step "Verifying toolchain" $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User') $allOk = $true # Helper: resolve a binary — tries hardcoded path first, then Get-Command (PATH) function Resolve-Tool { param([string]$HardcodedPath, [string]$CommandName) if ($HardcodedPath -and (Test-Path $HardcodedPath -PathType Leaf)) { return $HardcodedPath } $found = Get-Command $CommandName -ErrorAction SilentlyContinue if ($found) { return $found.Source } return $null } # dotnet $dotnetBin = Resolve-Tool '' 'dotnet' if ($dotnetBin) { $v = & $dotnetBin --version 2>&1 | Select-Object -First 1 Write-Host " [OK] dotnet: $v ($dotnetBin)" -ForegroundColor Green } else { Write-Host " [FAIL] dotnet not found" -ForegroundColor Red $allOk = $false } # python $pythonBin = Resolve-Tool 'C:\Python\python.exe' 'python' if ($pythonBin) { $v = & $pythonBin --version 2>&1 | Select-Object -First 1 Write-Host " [OK] python: $v ($pythonBin)" -ForegroundColor Green } else { Write-Host " [FAIL] python not found" -ForegroundColor Red $allOk = $false } # msbuild $msbuildBin = Resolve-Tool $msbuildPath 'msbuild' if ($msbuildBin) { $v = & $msbuildBin -version 2>&1 | Select-Object -First 1 Write-Host " [OK] msbuild: $v ($msbuildBin)" -ForegroundColor Green } else { Write-Host " [FAIL] msbuild not found at '$msbuildPath' and not in PATH" -ForegroundColor Red $allOk = $false } if (-not $allOk) { throw "Toolchain verification failed. See [FAIL] entries above. Setup aborted." } Write-Host "All toolchain checks passed." -ForegroundColor Green # ── Cleanup ─────────────────────────────────────────────────────────────────── if ($SkipCleanup) { Write-Host "`n[Setup] Skipping disk cleanup (-SkipCleanup switch set)." -ForegroundColor Yellow } else { Write-Step "Cleaning up disk" # 1. Windows Disk Cleanup (cleanmgr) — run with /sagerun:1 using preset flags # First register the flags via /sageset:1 in the registry (silent, no UI) $cleanKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches' $sageset = 1 $cleanCategories = @( 'Active Setup Temp Folders', 'BranchCache', 'Downloaded Program Files', 'Internet Cache Files', 'Memory Dump Files', 'Old ChkDsk Files', 'Previous Installations', 'Recycle Bin', 'Service Pack Cleanup', 'Setup Log Files', 'System error memory dump files', 'System error minidump files', 'Temporary Files', 'Temporary Setup Files', 'Thumbnail Cache', 'Update Cleanup', 'Upgrade Discarded Files', 'Windows Defender', 'Windows Error Reporting Archive Files', 'Windows Error Reporting Files', 'Windows Error Reporting Queue Files', 'Windows Error Reporting Temp Files', 'Windows ESD installation files', 'Windows Upgrade Log Files' ) foreach ($cat in $cleanCategories) { $keyPath = "$cleanKey\$cat" if (Test-Path $keyPath) { Set-ItemProperty -Path $keyPath -Name "StateFlags$('{0:D4}' -f $sageset)" -Value 2 -Type DWord -Force -ErrorAction SilentlyContinue } } Write-Host "Running cleanmgr /sagerun:$sageset (may take a few minutes)..." $proc = Start-Process -FilePath 'cleanmgr.exe' -ArgumentList "/sagerun:$sageset" -Wait -PassThru Write-Host "cleanmgr exited (code $($proc.ExitCode))." # 2. Clear Windows Update cache # Stop all services that might hold file locks inside SoftwareDistribution: # wuauserv — Windows Update (already Disabled from Step 6b, stop is idempotent) # UsoSvc — Update Orchestrator (already Disabled from Step 6b) # bits — Background Intelligent Transfer Service (downloads WU payloads) # dosvc — Delivery Optimization (peer-to-peer WU cache) # TrustedInstaller — Windows Modules Installer (unpacks .cab/.msu update packages) Write-Host "Stopping WU-adjacent services before cache clear..." foreach ($svc in 'wuauserv', 'UsoSvc', 'bits', 'dosvc', 'TrustedInstaller') { Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue } # Delete and recreate the Download folder — more reliable than 'Remove-Item *' # because locked files inside subdirectories silently fail with wildcard delete. $sdDownload = 'C:\Windows\SoftwareDistribution\Download' Remove-Item $sdDownload -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path $sdDownload -Force | Out-Null Write-Host "Windows Update download cache cleared." # 3. Clear CBS / component store logs Remove-Item 'C:\Windows\Logs\CBS\*' -Force -ErrorAction SilentlyContinue # 4. Clear TEMP folders Remove-Item 'C:\Windows\Temp\*' -Recurse -Force -ErrorAction SilentlyContinue Remove-Item "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue # 5. Clear Prefetch Remove-Item 'C:\Windows\Prefetch\*' -Force -ErrorAction SilentlyContinue # 6. Run DISM component store cleanup (removes superseded update components) Write-Host "Running DISM component store cleanup (StartComponentCleanup)..." $dism = Start-Process -FilePath 'dism.exe' ` -ArgumentList '/Online','/Cleanup-Image','/StartComponentCleanup','/ResetBase' ` -Wait -PassThru Write-Host "DISM exited (code $($dism.ExitCode))." Write-Host "Disk cleanup complete." # Validation — leftover artifacts from this run should be gone # Note: do NOT assert SoftwareDistribution\Download empty. # WaaSMedicSvc (Windows Update Medic Service) is tamper-protected — cannot be stopped — # and actively restores WU files immediately after deletion. Files reappearing here is # normal and harmless: WU is permanently disabled by Step 6b (service Disabled + GPO keys). # Report remaining size as informational only. $sdFiles = Get-ChildItem 'C:\Windows\SoftwareDistribution\Download' -Recurse -File -Force -ErrorAction SilentlyContinue $sdMB = [math]::Round(($sdFiles | Measure-Object -Property Length -Sum).Sum / 1MB, 1) Write-Host " SoftwareDistribution\Download: $($sdFiles.Count) file(s), ${sdMB} MB remaining (WaaSMedicSvc best-effort, WU is disabled)." Assert-Step 'Cleanup' 'wuauserv StartType still Disabled after cache clear' { (Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled' } } # end if (-not $SkipCleanup) # ── Final pre-snapshot validation ──────────────────────────────────────────── Write-Step "Final pre-snapshot validation" Assert-Step 'Final' 'WinRM service Running' { (Get-Service WinRM -ErrorAction Stop).Status -eq 'Running' } Assert-Step 'Final' 'all firewall profiles disabled' { -not (Get-NetFirewallProfile | Where-Object Enabled -eq $true) } Assert-Step 'Final' "user $BuildUsername present + admin" { (Get-LocalUser -Name $BuildUsername -ErrorAction Stop) -and (& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername) } Assert-Step 'Final' 'UAC EnableLUA=0' { (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA).EnableLUA -eq 0 } Assert-Step 'Final' 'dotnet, python, msbuild all resolvable' { $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User') (Get-Command dotnet -ErrorAction SilentlyContinue) -and (Test-Path 'C:\Python\python.exe' -PathType Leaf) -and (Test-Path $msbuildPath -PathType Leaf) } Assert-Step 'Final' 'Windows Update permanently disabled' { (Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled' } Assert-Step 'Final' 'no leftover installer scripts in C:\CI' { -not (Test-Path 'C:\CI\python_installer.exe') -and -not (Test-Path 'C:\CI\vs_BuildTools.exe') -and -not (Test-Path 'C:\CI\wu_worker.ps1') -and -not (Test-Path 'C:\CI\vs_worker.ps1') } Write-Host "All pre-snapshot checks passed." -ForegroundColor Green # ── Done ────────────────────────────────────────────────────────────────────── Write-Host "" Write-Host "Setup-WinBuild2025.ps1 complete. All Assert-Step checks passed." -ForegroundColor Green # Full next-steps banner is printed by Prepare-WinBuild2025.ps1 on the host. # If running this script standalone (manually inside the VM), follow the steps # in docs/WINDOWS-TEMPLATE-SETUP.md (shut down → snapshot BaseClean → power off). Write-Host ""