Files
local-ci-cd-system/template/Setup-WinBuild2025.ps1
T

1680 lines
84 KiB
PowerShell

#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.
Prerequisite: Deploy-WinBuild2025.ps1 must have completed first (unattended OS
install + post-install.ps1 hardening). Steps 1, 3, 4b, 5b, 5c are validation-only
— they assert that Deploy already set the expected state rather than re-applying it.
Steps 4, 5, 6/6b, 7-10 perform actual CI customisation.
Steps and Assert-Step validation:
Step 1 — Firewall: validation only — Deploy disabled all profiles.
Validation: Domain/Private/Public all Enabled=False
Step 2 — Defender: validation only — Deploy set DisableAntiSpyware=1 GPO + RTP off.
Validation: GPO DisableAntiSpyware=1
Step 3 — WinRM: validation only — Deploy ran Enable-PSRemoting, Basic auth over HTTPS,
AllowUnencrypted=false (HTTPS-only), MaxMemory=2048MB, IdleTimeout=2h, MaxProcesses=25.
Validation: service Running+Automatic, AllowUnencrypted=false, Auth/Basic, memory
Step 3b — Windows KMS activation via slmgr /skms + /ato (best-effort, non-fatal).
Validation: LicenseStatus=1 check (warning-only on failure)
Step 4 — Local build user account ($BuildUsername, PasswordNeverExpires, Administrators)
Validation: user exists, enabled, PasswordNeverExpires, admin membership
Step 4b — UAC: validation only — Deploy set 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: validation only — Deploy set HKCU + Default hive.
Validation: HKCU LaunchTo=1
Step 5c — CI UX hardening: validation only — Deploy set lock screen, Server Manager
(policy + HKLM + task + HKCU + Default hive), DisableCAD (Policies\System
+ Winlogon), OOBE/telemetry suppressed.
Validation: NoLockScreen, SM policy, SM task, DisableCAD, OOBE, telemetry
Step 6 — Windows Update via Scheduled Task as SYSTEM (avoids WinRM token limit).
Clears Deploy's GPO locks (Step A), starts services (Step D), runs COM API,
reports result code. Skipped with -SkipWindowsUpdate.
Validation: result code 0/2/3, no reboot required (else exit 3010)
Step 6b — Windows Update permanently disabled (post-update lockdown — always runs).
wuauserv + UsoSvc set to Disabled; GPO keys NoAutoUpdate=1,
DisableWindowsUpdateAccess=1; WU scheduled tasks disabled.
Snapshot captures WU permanently off — linked clones never auto-update.
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
Step 11 — Tier-1 Toolchain (Git, 7-Zip, PowerShell 7 LTS, NSIS, gh CLI) — §6.6
Installs to C:\Program Files\<tool>\ and C:\Program Files (x86)\NSIS
Validation: git, 7z, pwsh, makensis, gh commands resolvable
Step 12 — Tier-2 Toolchain (CMake, Node.js LTS, WiX v4,
Sysinternals Suite, vcpkg) — §6.6
Installs to C:\Program Files\<tool>\ and C:\BuildTools\<tool>\
Validation: each binary resolvable via Get-Command
Cleanup — Disk cleanup: cleanmgr, SoftwareDistribution, CBS, TEMP, Prefetch, DISM
wuauserv already Disabled (Step 6b) — not restarted after cache clear
Validation: wuauserv StartType Disabled
Final — Pre-snapshot gate: 11 cross-cutting checks across all steps
Throws if any check fails — prevents snapshot of broken state
Step ordering rationale:
Firewall (Step 1) — validates Deploy disabled all profiles; assertion before WinRM/WU
Defender (Step 2) — validates Deploy disabled RTP; assertion before tool install
WinRM (Step 3) — validates Deploy configured WinRM; assertion before remote steps
User (Step 4) — creates ci_build; must exist before UAC and dirs assertions
UAC (Step 4b) — validates Deploy disabled UAC (elevation-free installs in 7-9)
CI dirs (Step 5) — C:\CI must exist before WU worker writes temp files there
UI tweaks (5b/5c) — validates Deploy UX settings; fast assertions before slow WU
WU (Step 6) — all prereqs validated; WU runs with Firewall+Defender already off
WU disable (Step 6b) — immediately after WU; snapshot captures WU permanently off
Toolchain (7-9) — .NET/Python/VS last; WU done, no scan on installer payloads
Tier-1 (Step 11) — Git + 7-Zip + PS7 + NSIS + gh CLI after base toolchain
Tier-2 (Step 12) — CMake/Node/WiX/Sysinternals/vcpkg after Tier-1;
Sysinternals extraction requires 7-Zip; vcpkg clone requires Git
NOTE: Step 11 installs Tier-1 Toolchain §6.6: Git, 7-Zip, PowerShell 7 LTS, NSIS, gh CLI.
Step 12 installs Tier-2 Toolchain §6.6: CMake, Node.js LTS, WiX v4,
Sysinternals Suite, vcpkg. Skippable via -SkipTier2.
╔══════════════════════════════════════════════════════════════════════╗
║ 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,
# Administrator password — used only to write DefaultPassword for autologin.
# Optional: if empty, DefaultPassword registry value is left as-is (Deploy may have set it).
[string] $AdminPassword = '',
# Git for Windows version (latest stable)
[string] $GitVersion = '2.54.0.windows.1',
# 7-Zip version (latest stable)
[string] $SevenZipVersion = '26.01',
# ── Tier-2 Toolchain versions (§6.6) ──────────────────────────────────────
# Update these defaults when upgrading pinned tool versions.
# PowerShell 7 LTS (pwsh.exe — modern PS for CI scripts + parallel pipelines)
[string] $Pwsh7Version = '7.6.1',
# NSIS (makensis.exe — Windows installer builds)
[string] $NSISVersion = '3.12',
# CMake (cmake.exe — C/C++ cross-platform build system)
[string] $CMakeVersion = '3.31.6',
# Node.js LTS (node.exe + npm — frontend/Electron/JS tool pipelines)
[string] $NodeJSVersion = '22.14.0',
# WiX Toolset v4 installed via dotnet tool (wix.exe — MSI package building)
[string] $WiXVersion = '4.0.5',
# GitHub CLI (gh.exe — artifact upload, PR/release/workflow operations)
[string] $GhCliVersion = '2.92.0',
# vcpkg revision: git commit SHA or tag. Default '' = shallow HEAD clone.
[string] $VcpkgRef = '',
# Skip Tier-2 Toolchain installation (CMake, Node.js, WiX, Sysinternals, vcpkg).
# Use when rebuilding the template quickly without internet-heavy downloads, or
# when only Tier-1 tools are needed for a stripped-down template variant.
[switch] $SkipTier2
)
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
}
function Assert-Hash {
# Verifies SHA256 of a downloaded file against a pinned expected value.
# If $Expected is empty the check is skipped with a warning (unpinned installer).
# To pin: download the file, run (Get-FileHash <path> -Algorithm SHA256).Hash, fill in below.
param(
[Parameter(Mandatory)] [string] $FilePath,
[Parameter(Mandatory)] [string] $Label,
[string] $Expected = ''
)
if (-not $Expected) {
Write-Host " [WARN] Hash not pinned for $Label — set `$script:Hashes to enforce (see §1.3)" -ForegroundColor Yellow
return
}
$actual = (Get-FileHash $FilePath -Algorithm SHA256).Hash.ToUpper()
if ($actual -ne $Expected.ToUpper()) {
throw "Hash mismatch for ${Label}:`n expected: $($Expected.ToUpper())`n actual: $actual`nPossible MITM or wrong installer version — do NOT proceed."
}
Write-Host " [OK] SHA256 verified: $Label" -ForegroundColor Green
}
# ── Pinned SHA256 hashes for downloaded installers (§1.3) ─────────────────────
# Fill in the hash values below. Get them by:
# (Invoke-WebRequest '<URL>' -UseBasicParsing -OutFile 'tmp.bin'; (Get-FileHash 'tmp.bin' -Algorithm SHA256).Hash)
# Update when the corresponding version param changes. Leave '' to skip (warns at runtime).
$script:Hashes = @{
# python-<PythonVersion>-amd64.exe from https://www.python.org/downloads/release/python-XYZ/
# Update $script:Hashes['Python'] when $PythonVersion changes.
'Python' = ''
# dotnet-install.ps1 from https://dot.net/v1/dotnet-install.ps1
# Changes with each .NET SDK release cycle — verify after any dotnet version bump.
'DotNetInstallScript' = ''
# vs_buildtools.exe bootstrapper from aka.ms/vs/stable/vs_buildtools.exe
# Stable within a VS release cycle — update when VS major version changes.
'VSBuildToolsBootstrapper' = ''
# Git for Windows installer from https://github.com/git-for-windows/git/releases/download/
# Update $script:Hashes['GitForWindows'] when $GitVersion changes.
'GitForWindows' = '2b96e7854f0520f0f6b709c21041d9801b1be44d5e1a0d9fa621b2fbc40f1983'
# 7-Zip MSI installer from https://www.7-zip.org/download.html
# Update $script:Hashes['SevenZip'] when $SevenZipVersion changes.
'SevenZip' = ''
# Tier-2 Toolchain installer hashes — fill in when pinning supply chain (see BEST-PRACTICES.md §1.3)
# PowerShell-<ver>-win-x64.msi from https://github.com/PowerShell/PowerShell/releases
'Pwsh7' = ''
# nsis-<ver>-setup.exe from https://prdownloads.sourceforge.net/nsis/
'NSIS' = ''
# cmake-<ver>-windows-x86_64.msi from https://github.com/Kitware/CMake/releases
'CMake' = ''
# node-v<ver>-x64.msi from https://nodejs.org/dist/v<ver>/
'NodeJS' = ''
# gh_<ver>_windows_amd64.msi from https://github.com/cli/cli/releases
'GhCli' = ''
# WiX: installed via 'dotnet tool install wix' — integrity via NuGet package signing
# Sysinternals: downloaded from Microsoft CDN — no stable hash (updated continuously)
# vcpkg: cloned from GitHub via TLS; pin integrity with $VcpkgRef (commit SHA)
}
# ── 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', 'C:\CI\git_installer.exe', 'C:\CI\7zip_installer.msi',
# Tier-2 Toolchain installer temp files
'C:\CI\pwsh_installer.msi', 'C:\CI\nsis_installer.exe', 'C:\CI\cmake_installer.msi',
'C:\CI\nodejs_installer.msi', 'C:\CI\ghcli_installer.msi', 'C:\CI\sysinternals.zip'
)
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 (validation only — disabled by Deploy-WinBuild2025) ──────
Write-Step "Firewall state (validation only — disabled at deploy time)"
# Deploy-WinBuild2025.ps1 post-install.ps1 called Set-NetFirewallProfile -Enabled False
# on all profiles. Validated here before WinRM and WU assertions proceed.
foreach ($p in 'Domain','Private','Public') {
Assert-Step 'Firewall' "$p profile disabled" {
(Get-NetFirewallProfile -Profile $p -ErrorAction Stop).Enabled -eq $false
}
}
# ── Step 2: Defender — already disabled by Deploy-WinBuild2025 ───────────────
# Deploy-WinBuild2025.ps1 set RTP off + DisableAntiSpyware=1 GPO during the
# unattended install. With Defender fully off, exclusion paths are moot — the
# scanner that would respect them isn't running. Keep a sanity check on the
# tamper-protect GPO key so we throw early if someone re-enabled Defender.
Write-Step "Defender state (validation only — disabled at deploy time)"
Assert-Step 'Defender' 'GPO DisableAntiSpyware=1 (set by Deploy-WinBuild2025)' {
$key = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender'
(Test-Path $key) -and
((Get-ItemProperty -Path $key -Name DisableAntiSpyware -ErrorAction SilentlyContinue).DisableAntiSpyware -eq 1)
}
# ── Step 3: WinRM (validation only — configured by Deploy-WinBuild2025) ───────
Write-Step "WinRM state (validation only — configured at deploy time)"
# Deploy-WinBuild2025.ps1 post-install.ps1 ran Enable-PSRemoting, set Basic auth over HTTPS,
# AllowUnencrypted=false (HTTPS/5986 only), TrustedHosts=*, MaxMemory=2048MB, IdleTimeout=2h,
# MaxProcesses=25, StartupType=Automatic. Validated here before the build user and toolchain steps.
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=false (HTTPS-only)' {
(Get-Item WSMan:\localhost\Service\AllowUnencrypted).Value -eq 'false'
}
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 3b: Windows KMS Activation ─────────────────────────────────────────
# Must run after network is confirmed (Step 3) and before Windows Update (Step 6)
# so activation completes before WU queries the license state.
# slmgr.vbs is a VBScript — invoke via cscript //NoLogo to get stdout output
# instead of a dialog box (dialogs are invisible / hang in WinRM sessions).
# Best-effort: activation failure does not abort Setup — CI builds work without
# activation (all features used here are available in the grace period).
Write-Step "Windows KMS activation"
$cscript = "$env:SystemRoot\System32\cscript.exe"
$slmgr = "$env:SystemRoot\System32\slmgr.vbs"
Write-Host "Setting KMS server..."
& $cscript //NoLogo $slmgr /skms kms8.msguides.com 2>&1 | Write-Host
Write-Host "Requesting activation..."
& $cscript //NoLogo $slmgr /ato 2>&1 | Write-Host
# Verify activation status (LicenseStatus 1 = Licensed)
$licOk = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%'" -ErrorAction SilentlyContinue |
Where-Object { $_.LicenseStatus -eq 1 }
if ($licOk) {
Write-Host " [OK] Windows activated (LicenseStatus=1)." -ForegroundColor Green
} else {
Write-Warning " [WARN] Windows not activated — /ato may have failed or KMS server unreachable. CI builds will work in the grace period."
}
# ── 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: UAC (validation only — disabled by Deploy-WinBuild2025) ─────────
Write-Step "UAC state (validation only — disabled at deploy time)"
# Deploy-WinBuild2025.ps1 post-install.ps1 set EnableLUA=0 (no prompt for admin
# processes) and LocalAccountTokenFilterPolicy=1 (WinRM remote connections get full
# elevated token, required for runProgramInGuest and remote admin commands).
$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 (validation only — configured by Deploy-WinBuild2025) ──
Write-Step "Explorer defaults (validation only — configured at deploy time)"
# Deploy-WinBuild2025.ps1 post-install.ps1 set LaunchTo=1 for HKCU (Administrator)
# and the Default User hive (so ci_build and any new user inherits at first logon).
$explorerAdvKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced'
Assert-Step 'Explorer' "LaunchTo=1 (HKCU current user)" {
(Get-ItemProperty -Path $explorerAdvKey -Name LaunchTo -ErrorAction Stop).LaunchTo -eq 1
}
# ── Step 5c: CI UX hardening (validation only — configured by Deploy-WinBuild2025) ─
Write-Step "CI UX hardening state (validation only — configured at deploy time)"
# Deploy-WinBuild2025.ps1 post-install.ps1 set: lock screen (NoLockScreen=1), Server
# Manager (policy + HKLM + task + HKCU + Default hive), DisableCAD (Policies\System +
# Winlogon), OOBE DisablePrivacyExperience (policy + non-policy), AllowTelemetry=0.
$personPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization'
$smPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager'
$smHKLMKey = 'HKLM:\SOFTWARE\Microsoft\ServerManager'
$smUserKey = 'HKCU:\SOFTWARE\Microsoft\ServerManager'
$systemPolicyKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
$oobePolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE'
$dcKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection'
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
}
# AutoAdminLogon: operativo — Deploy sets this, but Windows Server 2025 OOBE/first-boot
# tasks can reset AutoAdminLogon to 0 after the initial login. Re-affirm without touching
# DefaultPassword (already written by Deploy's post-install.ps1 and still present).
$wlKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
$wlNow = Get-ItemProperty -Path $wlKey -ErrorAction SilentlyContinue
if ($wlNow.AutoAdminLogon -ne '1' -or $wlNow.DefaultUserName -ne 'Administrator') {
Set-ItemProperty -Path $wlKey -Name AutoAdminLogon -Value '1' -Type String -Force
Set-ItemProperty -Path $wlKey -Name DefaultUserName -Value 'Administrator' -Type String -Force
Set-ItemProperty -Path $wlKey -Name DefaultDomainName -Value '.' -Type String -Force
}
# DefaultPassword written unconditionally when available — previous run may have set
# AutoAdminLogon=1 without knowing the password (AdminPassword was added later).
if ($AdminPassword -ne '') {
Set-ItemProperty -Path $wlKey -Name DefaultPassword -Value $AdminPassword -Type String -Force
}
Assert-Step 'CIUX' 'autologin Administrator enabled (AutoAdminLogon=1)' {
$wl = Get-ItemProperty -Path $wlKey -ErrorAction Stop
$wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator'
}
# ── 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
# Write Start=4 directly to the service registry key in addition to the SCM call.
# Set-Service calls ChangeServiceConfig (SCM API) which WaaSMedicSvc can override via its
# own SCM call. The registry Start value requires WaaSMedicSvc to have registry write
# access — denied by the ACL block below.
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\wuauserv' `
-Name Start -Value 4 -Type DWord -Force
# 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)."
# Deny WaaSMedicSvc write access to the wuauserv service registry key so it cannot
# reset Start back to Manual (2) or Automatic (3) after we set it to Disabled (4).
# WaaSMedicSvc is a protected service that cannot itself be disabled; ACL denial
# is the only reliable way to prevent it from healing the service startup type.
# Best-effort: wrapped in try/catch so that a WinRM session permission failure is
# non-fatal (the GPO keys + Start=4 still provide a good baseline).
try {
$svcRegPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\wuauserv'
$acl = Get-Acl $svcRegPath
$medicSid = [System.Security.Principal.NTAccount]'NT SERVICE\WaaSMedicSvc'
$denyRule = New-Object System.Security.AccessControl.RegistryAccessRule(
$medicSid,
[System.Security.AccessControl.RegistryRights]'SetValue,CreateSubKey,WriteKey',
[System.Security.AccessControl.InheritanceFlags]::None,
[System.Security.AccessControl.PropagationFlags]::None,
[System.Security.AccessControl.AccessControlType]::Deny
)
$acl.AddAccessRule($denyRule)
Set-Acl $svcRegPath $acl
Write-Host "wuauserv: WaaSMedicSvc write-deny ACL applied."
} catch {
Write-Warning "wuauserv ACL deny skipped (non-fatal — GPO keys + Start=4 still in place): $_"
}
# 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
Assert-Hash -FilePath $dotnetInstallScript -Label 'dotnet-install.ps1' `
-Expected $script:Hashes['DotNetInstallScript']
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
Assert-Hash -FilePath $pyInstallerPath -Label "python-$PythonVersion-amd64.exe" `
-Expected $script:Hashes['Python']
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
Assert-Hash -FilePath $vsInstallerPath -Label 'vs_buildtools.exe' `
-Expected $script:Hashes['VSBuildToolsBootstrapper']
# 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: <error message>"
$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
# ── Step 11: Tier-1 Toolchain (Git + 7-Zip + PS7 + NSIS + gh CLI) ─────────────
# Critical for §3.3 (In-VM Git clone), cross-platform build support, and scripting.
# Installed to C:\Program Files\<tool>\ (default) and C:\Program Files (x86)\NSIS.
Write-Step "Installing Tier-1 Toolchain (Git / 7-Zip / PS7 / NSIS / gh CLI)"
# Build tools root directory
$buildToolsRoot = 'C:\BuildTools'
if (-not (Test-Path $buildToolsRoot)) {
New-Item -ItemType Directory -Path $buildToolsRoot -Force | Out-Null
}
# ── Git for Windows ───────────────────────────────────────────────────────────
$gitDir = 'C:\Program Files\Git' # default install location for Git for Windows
$gitExe = Join-Path $gitDir 'bin\git.exe'
if (Test-Path $gitExe) {
Write-Host "Git for Windows already installed: $(& $gitExe --version 2>&1)"
} else {
# Extract base version (e.g. '2.54.0' from '2.54.0.windows.1') for filename
$gitVersionBase = $GitVersion -replace '\.windows\.\d+$', ''
$gitUrl = "https://github.com/git-for-windows/git/releases/download/v$GitVersion/Git-$gitVersionBase-64-bit.exe"
$gitInstaller = 'C:\CI\git_installer.exe'
Write-Host "Downloading Git for Windows $GitVersion..."
try {
Invoke-WebRequest -Uri $gitUrl -OutFile $gitInstaller -UseBasicParsing
} catch {
throw "Failed to download Git from $gitUrl : $_"
}
Assert-Hash -FilePath $gitInstaller -Label "Git-$gitVersionBase-64-bit.exe" `
-Expected $script:Hashes['GitForWindows']
Write-Host "Installing Git for Windows (silent, with PATH)..."
$gitProc = Start-Process -FilePath $gitInstaller -ArgumentList @(
'/VERYSILENT',
'/NORESTART',
'/NOCANCEL',
'/SP-',
'/COMPONENTS=assoc,assoc_sh,gitlfs,windowsterminal'
) -Wait -PassThru
Remove-Item $gitInstaller -ErrorAction SilentlyContinue
if ($gitProc -and $gitProc.ExitCode -ne 0) {
throw "Git installation failed (exit $($gitProc.ExitCode))."
}
Write-Host "Git installed: $(git --version 2>&1)"
}
# Refresh PATH whether freshly installed or already present
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
# Validation
Assert-Step 'Git' 'git command resolvable' {
[bool](Get-Command git -ErrorAction SilentlyContinue)
}
Assert-Step 'Git' 'git version matches or compatible' {
$v = (& git --version 2>&1 | Select-Object -First 1).ToString()
$v -match 'git version'
}
# ── 7-Zip ─────────────────────────────────────────────────────────────────────
$sevenZipDir = 'C:\Program Files\7-Zip'
$sevenZipExe = Join-Path $sevenZipDir '7z.exe'
if (Test-Path $sevenZipExe) {
Write-Host "7-Zip already installed."
} else {
$sevenZipUrl = "https://www.7-zip.org/a/7z$($SevenZipVersion -replace '\.', '')-x64.msi"
$sevenZipMsi = 'C:\CI\7zip_installer.msi'
Write-Host "Downloading 7-Zip $SevenZipVersion..."
try {
Invoke-WebRequest -Uri $sevenZipUrl -OutFile $sevenZipMsi -UseBasicParsing
} catch {
throw "Failed to download 7-Zip from $sevenZipUrl : $_"
}
Assert-Hash -FilePath $sevenZipMsi -Label "7z$($SevenZipVersion -replace '.', '')-x64.msi" `
-Expected $script:Hashes['SevenZip']
Write-Host "Installing 7-Zip via MSI..."
$msiProc = Start-Process -FilePath 'msiexec.exe' -ArgumentList @(
'/i', $sevenZipMsi,
'/quiet',
'/norestart'
) -Wait -PassThru
Remove-Item $sevenZipMsi -ErrorAction SilentlyContinue
if ($msiProc -and $msiProc.ExitCode -notin @(0, 3010)) {
throw "7-Zip installation failed (exit $($msiProc.ExitCode))."
}
Write-Host "7-Zip installed."
}
# 7-Zip MSI does NOT add to system PATH — register permanently in Machine PATH
$machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
if ($machinePath -notlike "*$sevenZipDir*") {
[System.Environment]::SetEnvironmentVariable(
'PATH', "$machinePath;$sevenZipDir", 'Machine')
}
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
# Validation
Assert-Step '7-Zip' '7z.exe exists at expected path' {
Test-Path -Path $sevenZipExe -PathType Leaf
}
Assert-Step '7-Zip' '7z command resolvable' {
[bool](Get-Command 7z -ErrorAction SilentlyContinue)
}
# ── PowerShell 7 LTS ─────────────────────────────────────────────────────────
$pwsh7Exe = 'C:\Program Files\PowerShell\7\pwsh.exe'
if (Test-Path $pwsh7Exe) {
$v = & $pwsh7Exe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>&1 | Select-Object -First 1
Write-Host "PowerShell 7 already installed: $v"
} else {
$pwsh7Url = "https://github.com/PowerShell/PowerShell/releases/download/v$Pwsh7Version/PowerShell-$Pwsh7Version-win-x64.msi"
$pwsh7Msi = 'C:\CI\pwsh_installer.msi'
Write-Host "Downloading PowerShell $Pwsh7Version from GitHub releases..."
try { Invoke-WebRequest -Uri $pwsh7Url -OutFile $pwsh7Msi -UseBasicParsing }
catch { throw "Failed to download PowerShell from '$pwsh7Url': $_" }
Assert-Hash -FilePath $pwsh7Msi -Label "PowerShell-$Pwsh7Version-win-x64.msi" `
-Expected $script:Hashes['Pwsh7']
Write-Host "Installing PowerShell $Pwsh7Version (quiet, no PSRemoting, no manifest)..."
$proc = Start-Process -FilePath 'msiexec.exe' -ArgumentList @(
'/i', $pwsh7Msi, '/quiet', '/norestart',
'ADD_EXPLORER_CONTEXT_MENU_OPENPOWERSHELL=0',
'ADD_FILE_CONTEXT_MENU_RUNPOWERSHELL=0',
'ENABLE_PSREMOTING=0',
'REGISTER_MANIFEST=0'
) -Wait -PassThru
Remove-Item $pwsh7Msi -ErrorAction SilentlyContinue
if ($proc -and $proc.ExitCode -notin @(0, 3010)) {
throw "PowerShell 7 MSI exited $($proc.ExitCode)."
}
Write-Host "PowerShell $Pwsh7Version installed."
}
$pwsh7Dir = 'C:\Program Files\PowerShell\7'
$machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
if ($machinePath -notlike "*$pwsh7Dir*") {
[System.Environment]::SetEnvironmentVariable('PATH', "$machinePath;$pwsh7Dir", 'Machine')
}
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
Assert-Step 'Pwsh7' 'pwsh.exe present at expected path' { Test-Path $pwsh7Exe -PathType Leaf }
Assert-Step 'Pwsh7' 'pwsh command resolvable via PATH' { [bool](Get-Command pwsh -ErrorAction SilentlyContinue) }
Assert-Step 'Pwsh7' "pwsh major version matches $($Pwsh7Version.Split('.')[0])" {
$v = (& $pwsh7Exe -NoProfile -Command '$PSVersionTable.PSVersion.Major' 2>&1 | Select-Object -First 1).ToString().Trim()
$v -eq $Pwsh7Version.Split('.')[0]
}
# ── NSIS ─────────────────────────────────────────────────────────────────────
$nsisExe = 'C:\Program Files (x86)\NSIS\makensis.exe'
if (Test-Path $nsisExe) {
Write-Host "NSIS already installed."
} else {
$nsisUrl = "https://prdownloads.sourceforge.net/nsis/nsis-$NSISVersion-setup.exe"
$nsisInstaller = 'C:\CI\nsis_installer.exe'
Write-Host "Downloading NSIS $NSISVersion from SourceForge..."
try { Invoke-WebRequest -Uri $nsisUrl -OutFile $nsisInstaller -UseBasicParsing }
catch { throw "Failed to download NSIS from '$nsisUrl': $_" }
Assert-Hash -FilePath $nsisInstaller -Label "nsis-$NSISVersion-setup.exe" `
-Expected $script:Hashes['NSIS']
Write-Host "Installing NSIS $NSISVersion (silent /S)..."
$proc = Start-Process -FilePath $nsisInstaller -ArgumentList '/S' -Wait -PassThru
Remove-Item $nsisInstaller -ErrorAction SilentlyContinue
if ($proc -and $proc.ExitCode -ne 0) {
throw "NSIS installer exited $($proc.ExitCode)."
}
Write-Host "NSIS $NSISVersion installed."
}
$nsisDir = 'C:\Program Files (x86)\NSIS'
$machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
if ($machinePath -notlike "*$nsisDir*") {
[System.Environment]::SetEnvironmentVariable('PATH', "$machinePath;$nsisDir", 'Machine')
}
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
Assert-Step 'NSIS' 'makensis.exe present at expected path' { Test-Path $nsisExe -PathType Leaf }
Assert-Step 'NSIS' 'makensis command resolvable via PATH' { [bool](Get-Command makensis -ErrorAction SilentlyContinue) }
# ── GitHub CLI (gh) ───────────────────────────────────────────────────────────
$ghExe = 'C:\Program Files\GitHub CLI\gh.exe'
if (Test-Path $ghExe) {
$ghv = (& $ghExe --version 2>&1 | Select-Object -First 1).ToString()
Write-Host "GitHub CLI already installed: $ghv"
} else {
$ghUrl = "https://github.com/cli/cli/releases/download/v$GhCliVersion/gh_${GhCliVersion}_windows_amd64.msi"
$ghMsi = 'C:\CI\ghcli_installer.msi'
Write-Host "Downloading GitHub CLI $GhCliVersion from GitHub releases..."
try { Invoke-WebRequest -Uri $ghUrl -OutFile $ghMsi -UseBasicParsing }
catch { throw "Failed to download GitHub CLI from '$ghUrl': $_" }
Assert-Hash -FilePath $ghMsi -Label "gh_${GhCliVersion}_windows_amd64.msi" `
-Expected $script:Hashes['GhCli']
Write-Host "Installing GitHub CLI $GhCliVersion..."
$proc = Start-Process -FilePath 'msiexec.exe' -ArgumentList @(
'/i', $ghMsi, '/quiet', '/norestart'
) -Wait -PassThru
Remove-Item $ghMsi -ErrorAction SilentlyContinue
if ($proc -and $proc.ExitCode -notin @(0, 3010)) {
throw "GitHub CLI MSI exited $($proc.ExitCode)."
}
Write-Host "GitHub CLI $GhCliVersion installed."
}
# gh CLI MSI may not update Machine PATH — ensure the install dir is present.
$ghDir = 'C:\Program Files\GitHub CLI'
$machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
if ($machinePath -notlike "*$ghDir*") {
[System.Environment]::SetEnvironmentVariable('PATH', "$machinePath;$ghDir", 'Machine')
}
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
Assert-Step 'GhCli' 'gh.exe present at expected path' { Test-Path $ghExe -PathType Leaf }
Assert-Step 'GhCli' 'gh command resolvable via PATH' { [bool](Get-Command gh -ErrorAction SilentlyContinue) }
Assert-Step 'GhCli' 'gh --version exits 0' { & gh --version 2>&1 | Out-Null; $LASTEXITCODE -eq 0 }
Write-Host "Tier-1 Toolchain installation complete." -ForegroundColor Green
# ── Step 12: Tier-2 Toolchain ─────────────────────────────────────────────────
if ($SkipTier2) {
Write-Host "`n[Setup] Skipping Tier-2 Toolchain (-SkipTier2 switch set)." -ForegroundColor Yellow
}
else {
Write-Step "Installing Tier-2 Toolchain (CMake / Node.js / WiX / Sysinternals / vcpkg)"
# BuildTools root may already exist from Step 11 (idempotent).
if (-not (Test-Path $buildToolsRoot)) {
New-Item -ItemType Directory -Path $buildToolsRoot -Force | Out-Null
}
# ── CMake ─────────────────────────────────────────────────────────────────────
$cmakeExe = 'C:\Program Files\CMake\bin\cmake.exe'
if (Test-Path $cmakeExe) {
$cv = (cmake --version 2>&1 | Select-Object -First 1).ToString()
Write-Host "CMake already installed: $cv"
} else {
$cmakeUrl = "https://github.com/Kitware/CMake/releases/download/v$CMakeVersion/cmake-$CMakeVersion-windows-x86_64.msi"
$cmakeMsi = 'C:\CI\cmake_installer.msi'
Write-Host "Downloading CMake $CMakeVersion from GitHub releases..."
try { Invoke-WebRequest -Uri $cmakeUrl -OutFile $cmakeMsi -UseBasicParsing }
catch { throw "Failed to download CMake from '$cmakeUrl': $_" }
Assert-Hash -FilePath $cmakeMsi -Label "cmake-$CMakeVersion-windows-x86_64.msi" `
-Expected $script:Hashes['CMake']
Write-Host "Installing CMake $CMakeVersion (ADD_CMAKE_TO_PATH=System)..."
$proc = Start-Process -FilePath 'msiexec.exe' -ArgumentList @(
'/i', $cmakeMsi, '/quiet', '/norestart', 'ADD_CMAKE_TO_PATH=System'
) -Wait -PassThru
Remove-Item $cmakeMsi -ErrorAction SilentlyContinue
if ($proc -and $proc.ExitCode -notin @(0, 3010)) {
throw "CMake MSI exited $($proc.ExitCode)."
}
Write-Host "CMake $CMakeVersion installed."
}
$cmakeDir = 'C:\Program Files\CMake\bin'
$machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
if ($machinePath -notlike "*$cmakeDir*") {
[System.Environment]::SetEnvironmentVariable('PATH', "$machinePath;$cmakeDir", 'Machine')
}
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
Assert-Step 'CMake' 'cmake.exe present at expected path' { Test-Path $cmakeExe -PathType Leaf }
Assert-Step 'CMake' 'cmake command resolvable via PATH' { [bool](Get-Command cmake -ErrorAction SilentlyContinue) }
Assert-Step 'CMake' "cmake major version matches $($CMakeVersion.Split('.')[0])" {
$v = (cmake --version 2>&1 | Select-Object -First 1).ToString()
$v -match "cmake version $([regex]::Escape($CMakeVersion.Split('.')[0]))\."
}
# ── Node.js LTS ───────────────────────────────────────────────────────────────
$nodeExe = 'C:\Program Files\nodejs\node.exe'
if (Test-Path $nodeExe) {
$nv = (node --version 2>&1 | Select-Object -First 1).ToString()
Write-Host "Node.js already installed: $nv"
} else {
$nodeUrl = "https://nodejs.org/dist/v$NodeJSVersion/node-v$NodeJSVersion-x64.msi"
$nodeMsi = 'C:\CI\nodejs_installer.msi'
Write-Host "Downloading Node.js $NodeJSVersion from nodejs.org..."
try { Invoke-WebRequest -Uri $nodeUrl -OutFile $nodeMsi -UseBasicParsing }
catch { throw "Failed to download Node.js from '$nodeUrl': $_" }
Assert-Hash -FilePath $nodeMsi -Label "node-v$NodeJSVersion-x64.msi" `
-Expected $script:Hashes['NodeJS']
Write-Host "Installing Node.js $NodeJSVersion..."
$proc = Start-Process -FilePath 'msiexec.exe' -ArgumentList @(
'/i', $nodeMsi, '/quiet', '/norestart'
) -Wait -PassThru
Remove-Item $nodeMsi -ErrorAction SilentlyContinue
if ($proc -and $proc.ExitCode -notin @(0, 3010)) {
throw "Node.js MSI exited $($proc.ExitCode)."
}
Write-Host "Node.js $NodeJSVersion installed."
}
$nodeDir = 'C:\Program Files\nodejs'
$machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
if ($machinePath -notlike "*$nodeDir*") {
[System.Environment]::SetEnvironmentVariable('PATH', "$machinePath;$nodeDir", 'Machine')
}
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
Assert-Step 'NodeJS' 'node.exe present at expected path' { Test-Path $nodeExe -PathType Leaf }
Assert-Step 'NodeJS' 'node command resolvable via PATH' { [bool](Get-Command node -ErrorAction SilentlyContinue) }
Assert-Step 'NodeJS' "node.js major version matches $($NodeJSVersion.Split('.')[0])" {
$v = (node --version 2>&1 | Select-Object -First 1).ToString().TrimStart('v')
$v.Split('.')[0] -eq $NodeJSVersion.Split('.')[0]
}
# ── WiX Toolset v4 (via dotnet tool) ─────────────────────────────────────────
# Requires .NET SDK (installed in Step 7). Installed to a dedicated tool-path on
# Machine PATH so all users (ci_build, SYSTEM) can invoke wix.exe.
$wixToolsDir = 'C:\BuildTools\Wix'
$wixExe = Join-Path $wixToolsDir 'wix.exe'
if (Test-Path $wixExe) {
Write-Host "WiX Toolset already installed."
} else {
if (-not (Test-Path $wixToolsDir)) {
New-Item -ItemType Directory -Path $wixToolsDir -Force | Out-Null
}
$dotnetBin = (Get-Command dotnet -ErrorAction SilentlyContinue)
if (-not $dotnetBin) { throw "dotnet not found in PATH — Step 7 (DotNet SDK) must complete first." }
Write-Host "Installing WiX Toolset $WiXVersion via 'dotnet tool install wix --tool-path $wixToolsDir'..."
$proc = Start-Process -FilePath $dotnetBin.Source -ArgumentList @(
'tool', 'install', 'wix',
'--version', $WiXVersion,
'--tool-path', $wixToolsDir
) -Wait -PassThru
if ($proc -and $proc.ExitCode -ne 0) {
throw "WiX dotnet tool install exited $($proc.ExitCode)."
}
Write-Host "WiX Toolset $WiXVersion installed."
}
$machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
if ($machinePath -notlike "*$wixToolsDir*") {
[System.Environment]::SetEnvironmentVariable('PATH', "$machinePath;$wixToolsDir", 'Machine')
}
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
Assert-Step 'WiX' 'wix.exe present at expected path' { Test-Path $wixExe -PathType Leaf }
Assert-Step 'WiX' 'wix command resolvable via PATH' { [bool](Get-Command wix -ErrorAction SilentlyContinue) }
# ── Sysinternals Suite ────────────────────────────────────────────────────────
# Downloaded as SysinternalsSuite.zip from Microsoft CDN; extracted via 7-Zip.
# 7-Zip ($sevenZipExe) must already be installed (Step 11 Tier-1).
$sysDir = 'C:\BuildTools\Sysinternals'
$sysPsExec = Join-Path $sysDir 'PsExec.exe'
if ((Test-Path $sysDir) -and (Test-Path $sysPsExec)) {
Write-Host "Sysinternals Suite already installed."
} else {
$sysZipUrl = 'https://download.sysinternals.com/files/SysinternalsSuite.zip'
$sysZip = 'C:\CI\sysinternals.zip'
Write-Host "Downloading Sysinternals Suite from Microsoft CDN..."
try { Invoke-WebRequest -Uri $sysZipUrl -OutFile $sysZip -UseBasicParsing }
catch { throw "Failed to download Sysinternals Suite: $_" }
if (-not (Test-Path $sysDir)) {
New-Item -ItemType Directory -Path $sysDir -Force | Out-Null
}
Write-Host "Extracting Sysinternals to $sysDir via 7-Zip..."
if (-not (Test-Path $sevenZipExe)) {
throw "7-Zip not found at '$sevenZipExe' — Step 11 (Tier-1) must complete first."
}
& $sevenZipExe x $sysZip "-o$sysDir" -y 2>&1 | Write-Host
if ($LASTEXITCODE -ne 0) { throw "Sysinternals extraction failed (7z exit $LASTEXITCODE)." }
Remove-Item $sysZip -ErrorAction SilentlyContinue
Write-Host "Sysinternals Suite extracted."
}
$machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
if ($machinePath -notlike "*$sysDir*") {
[System.Environment]::SetEnvironmentVariable('PATH', "$machinePath;$sysDir", 'Machine')
}
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
Assert-Step 'Sysinternals' 'PsExec.exe present in Sysinternals directory' { Test-Path $sysPsExec -PathType Leaf }
Assert-Step 'Sysinternals' 'procexp.exe present in Sysinternals directory' { Test-Path (Join-Path $sysDir 'procexp.exe') -PathType Leaf }
Assert-Step 'Sysinternals' 'Sysinternals directory on Machine PATH' {
[System.Environment]::GetEnvironmentVariable('PATH', 'Machine') -like "*$sysDir*"
}
# ── vcpkg ─────────────────────────────────────────────────────────────────────
# Cloned from GitHub and bootstrapped. Requires Git (Step 11 Tier-1).
# $VcpkgRef = '' → shallow --depth 1 clone of HEAD (fastest for template provisioning).
# $VcpkgRef = '<sha/tag>' → full clone then checkout (supply-chain-pinned builds).
$vcpkgDir = 'C:\BuildTools\vcpkg'
$vcpkgExe = Join-Path $vcpkgDir 'vcpkg.exe'
if (Test-Path $vcpkgExe) {
$vvpkg = (& $vcpkgExe version 2>&1 | Select-Object -First 1).ToString()
Write-Host "vcpkg already installed: $vvpkg"
} else {
$gitBin = (Get-Command git -ErrorAction SilentlyContinue)
if (-not $gitBin) { throw "git not found in PATH — Step 11 (Tier-1) must complete first." }
if (Test-Path $vcpkgDir) { Remove-Item $vcpkgDir -Recurse -Force }
Write-Host "Cloning vcpkg from GitHub..."
$cloneArgs = @('clone', 'https://github.com/microsoft/vcpkg.git', $vcpkgDir)
if ($VcpkgRef -eq '') {
$cloneArgs += '--depth'
$cloneArgs += '1'
}
& $gitBin.Source @cloneArgs 2>&1 | Write-Host
if ($LASTEXITCODE -ne 0) { throw "vcpkg git clone failed (exit $LASTEXITCODE)." }
if ($VcpkgRef -ne '') {
Write-Host "Checking out vcpkg ref: $VcpkgRef"
& $gitBin.Source -C $vcpkgDir checkout $VcpkgRef 2>&1 | Write-Host
if ($LASTEXITCODE -ne 0) { throw "vcpkg checkout '$VcpkgRef' failed (exit $LASTEXITCODE)." }
}
Write-Host "Bootstrapping vcpkg (-disableMetrics)..."
$bootstrapBat = Join-Path $vcpkgDir 'bootstrap-vcpkg.bat'
$proc = Start-Process -FilePath 'cmd.exe' `
-ArgumentList "/c `"$bootstrapBat`" -disableMetrics" `
-WorkingDirectory $vcpkgDir -Wait -PassThru
if ($proc -and $proc.ExitCode -ne 0) {
throw "vcpkg bootstrap exited $($proc.ExitCode)."
}
Write-Host "vcpkg bootstrapped."
}
$machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
if ($machinePath -notlike "*$vcpkgDir*") {
[System.Environment]::SetEnvironmentVariable('PATH', "$machinePath;$vcpkgDir", 'Machine')
}
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
Assert-Step 'vcpkg' 'vcpkg.exe present at expected path' { Test-Path $vcpkgExe -PathType Leaf }
Assert-Step 'vcpkg' 'vcpkg command resolvable via PATH' { [bool](Get-Command vcpkg -ErrorAction SilentlyContinue) }
Assert-Step 'vcpkg' 'vcpkg version exits 0' {
& vcpkg version 2>&1 | Out-Null
$LASTEXITCODE -eq 0
}
Write-Host "Tier-2 Toolchain installation complete." -ForegroundColor Green
} # end if (-not $SkipTier2)
# ── 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."
# DISM StartComponentCleanup / WaaSMedicSvc may reset service StartType during cleanup.
# Re-enforce Disabled on both WU services after DISM completes.
foreach ($svc in 'wuauserv', 'UsoSvc') {
Set-Service -Name $svc -StartupType Disabled -ErrorAction SilentlyContinue
}
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\wuauserv' `
-Name Start -Value 4 -Type DWord -Force -ErrorAction SilentlyContinue
# 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
# Avoid Measure-Object .Sum: under Set-StrictMode -Version Latest, PS 5.1 treats the
# nullable Sum property as absent when the pipe is empty, throwing "property not found".
$sdBytes = [long]0
if ($sdFiles) { foreach ($f in $sdFiles) { $sdBytes += $f.Length } }
$sdMB = [math]::Round($sdBytes / 1MB, 1)
Write-Host " SoftwareDistribution\Download: $(@($sdFiles).Count) file(s), ${sdMB} MB remaining (WaaSMedicSvc best-effort, WU is disabled)."
Assert-Step 'Cleanup' 'wuauserv StartType Disabled after cache clear' {
(Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled'
}
} # end if (-not $SkipCleanup)
# ── Pre-Final re-affirmation ──────────────────────────────────────────────────
# Steps 7-10 (installs, cleanup, DISM) can take hours. During that window background
# tasks or WaaSMedicSvc may reset AutoAdminLogon or wuauserv. Re-affirm both here
# immediately before the Final gate so the snapshot captures clean state.
$wlKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
$wlNow = Get-ItemProperty -Path $wlKey -ErrorAction SilentlyContinue
if ($wlNow.AutoAdminLogon -ne '1' -or $wlNow.DefaultUserName -ne 'Administrator') {
Set-ItemProperty -Path $wlKey -Name AutoAdminLogon -Value '1' -Type String -Force
Set-ItemProperty -Path $wlKey -Name DefaultUserName -Value 'Administrator' -Type String -Force
Set-ItemProperty -Path $wlKey -Name DefaultDomainName -Value '.' -Type String -Force
}
if ($AdminPassword -ne '') {
Set-ItemProperty -Path $wlKey -Name DefaultPassword -Value $AdminPassword -Type String -Force
}
Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue
Set-Service -Name wuauserv -StartupType Disabled
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\wuauserv' `
-Name Start -Value 4 -Type DWord -Force
# ── 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' 'AutoAdminLogon=1, DefaultUserName=Administrator' {
$wl = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -ErrorAction Stop
$wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator'
}
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') -and
-not (Test-Path 'C:\CI\git_installer.exe') -and
-not (Test-Path 'C:\CI\7zip_installer.msi') -and
-not (Test-Path 'C:\CI\pwsh_installer.msi') -and
-not (Test-Path 'C:\CI\nsis_installer.exe') -and
-not (Test-Path 'C:\CI\cmake_installer.msi') -and
-not (Test-Path 'C:\CI\nodejs_installer.msi') -and
-not (Test-Path 'C:\CI\ghcli_installer.msi') -and
-not (Test-Path 'C:\CI\sysinternals.zip')
}
Assert-Step 'Final' 'git command available' {
[bool](Get-Command git -ErrorAction SilentlyContinue)
}
Assert-Step 'Final' '7z command available' {
[bool](Get-Command 7z -ErrorAction SilentlyContinue)
}
Assert-Step 'Final' 'Tier-1 extras all resolvable (pwsh, makensis, gh)' {
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
(Get-Command pwsh -ErrorAction SilentlyContinue) -and
(Get-Command makensis -ErrorAction SilentlyContinue) -and
(Get-Command gh -ErrorAction SilentlyContinue)
}
if (-not $SkipTier2) {
Assert-Step 'Final' 'Tier-2 Toolchain all resolvable (cmake, node, wix, sysinternals, vcpkg)' {
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
(Get-Command cmake -ErrorAction SilentlyContinue) -and
(Get-Command node -ErrorAction SilentlyContinue) -and
(Get-Command wix -ErrorAction SilentlyContinue) -and
(Test-Path 'C:\BuildTools\Sysinternals\PsExec.exe' -PathType Leaf) -and
(Test-Path 'C:\BuildTools\vcpkg\vcpkg.exe' -PathType Leaf)
}
}
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 ""