6b2a23b8f9
Setup-WinBuild2025.ps1: default SevenZipVersion '24.07' → '26.01' TODO.md: Tier-1 table + bullet list updated to current versions TEST-PLAN: expected version strings and checklist updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1286 lines
62 KiB
PowerShell
1286 lines
62 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 for Windows + 7-Zip) — §6.6
|
|
Installs to C:\Program Files (Git default) and C:\Program Files\7-Zip
|
|
Validation: git and 7z commands resolvable, executables present
|
|
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: 9 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 after base toolchain; enables §3.3 in-VM clone
|
|
|
|
NOTE: Step 11 installs Git for Windows + 7-Zip (Tier-1 Toolchain §6.6).
|
|
This enables §3.3 (In-VM Git clone) and cross-platform build support.
|
|
|
|
╔══════════════════════════════════════════════════════════════════════╗
|
|
║ 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'
|
|
)
|
|
|
|
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' = ''
|
|
}
|
|
|
|
# ── 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'
|
|
)
|
|
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 for Windows + 7-Zip) ──────────────────────
|
|
# Critical for §3.3 (In-VM Git clone) and cross-platform build support.
|
|
# Installed to C:\BuildTools\<tool>\ for clear separation from C:\CI\ (runtime artifacts).
|
|
|
|
Write-Step "Installing Tier-1 Toolchain (Git + 7-Zip)"
|
|
|
|
# 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)
|
|
}
|
|
|
|
Write-Host "Tier-1 Toolchain installation complete." -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."
|
|
|
|
# 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')
|
|
}
|
|
Assert-Step 'Final' 'git command available' {
|
|
[bool](Get-Command git -ErrorAction SilentlyContinue)
|
|
}
|
|
Assert-Step 'Final' '7z command available' {
|
|
[bool](Get-Command 7z -ErrorAction SilentlyContinue)
|
|
}
|
|
|
|
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 ""
|