diff --git a/AUTOUNATTEND.md b/AUTOUNATTEND.md
index a3e75a6..8722d1b 100644
--- a/AUTOUNATTEND.md
+++ b/AUTOUNATTEND.md
@@ -1,27 +1,19 @@
# AutoUnattend.xml Explained
-This file automates only the minimal parts of Windows 11 setup needed for this repo. It intentionally avoids aggressive install-time customization so Windows Setup stays as reliable as possible.
+This file is now intentionally ultra-minimal. It avoids changing Windows Setup itself and only kicks off post-install automation after first login.
## How It Works
The file runs in three phases during Windows installation:
-### 1. windowsPE (Installation Phase)
-
-Runs while Windows is being installed to the disk.
-
-- Bypasses TPM/Secure Boot/RAM checks (helpful for VMs)
-- Leaves disk and partition selection to the user in Windows Setup
-- Configures language/locale to en-US
-
-### 2. oobeSystem (First Boot)
+### 1. oobeSystem (First Boot)
Runs when Windows boots for the first time.
-- Hides EULA page
-- Hides OEM registration
-- Forces local account creation (no Microsoft account)
-- Sets privacy to minimal data collection
+- Leaves the Windows installer completely manual until OOBE is finished
+- Uses only a few standard OOBE settings (`ProtectYourPC`, `HideEULAPage`, wireless setup visibility)
+- Waits for network connectivity
+- Starts `bootstrap.ps1`
**You'll still see:**
- Region/language selection
@@ -31,17 +23,18 @@ Runs when Windows boots for the first time.
## What Happens on First Login
-1. Windows completes the normal account setup flow
-2. The system waits for network connectivity
-3. **bootstrap.ps1 runs automatically** via FirstLogonCommands
+1. Complete the normal Windows setup flow yourself
+2. Sign in for the first time
+3. The system waits for network connectivity
+4. **bootstrap.ps1 runs automatically** via FirstLogonCommands
-Install-time tweaking is intentionally minimal. Bootstrap handles app installation via WinGet and system customization via Sophia Script after first login.
+Everything else now happens in `bootstrap.ps1`: app installs, debloat, Sophia, restore shortcuts, and post-install tweaks.
**If bootstrap fails or you want to re-run it:** Just run `C:\Setup\bootstrap.ps1` manually.
## Customization
-This file is intentionally minimal. If you add more install-time customization later, keep it small and retest Setup carefully.
+This file should stay minimal. If Windows Setup errors return, prefer moving more logic into `bootstrap.ps1` rather than adding it back here.
## Logs
diff --git a/CLAUDE.md b/CLAUDE.md
index 08c887d..9fb160c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -89,7 +89,7 @@ declarative-windows/
# 3. Boot from ISO - choose the target disk/partition, then setup continues with:
# - autounattend.xml configures Windows setup after disk selection
-# - Files copied to C:\Setup via $OEM$ folder
+# - Files copied to C:\Setup via sources\$OEM$ folder
# - bootstrap.ps1 runs automatically after first login
# - Desktop shortcut created for manual re-runs
@@ -101,13 +101,13 @@ declarative-windows/
```
Custom-ISO:\
├── sources\ # Windows install files
-├── autounattend.xml # Placed in root for auto-execution
-└── $OEM$\
- └── $$\
- └── Setup\ # Files copied to C:\Setup during install
- ├── bootstrap.ps1
- ├── apps.json
- └── Sophia.ps1
+│ └── $OEM$\
+│ └── $$\
+│ └── Setup\ # Files copied to C:\Setup during install
+│ ├── bootstrap.ps1
+│ ├── apps.json
+│ └── Sophia.ps1
+└── autounattend.xml # Placed in root for auto-execution
```
**On installed system:**
@@ -132,7 +132,7 @@ If you don't want to generate a custom ISO, manually create USB structure:
```bash
# 1. Create Windows install USB with Rufus/Media Creation Tool
-# 2. Copy files to USB in $OEM$ structure (see above)
+# 2. Copy files to USB in sources\$OEM$ structure (see above)
# 3. Copy autounattend.xml to USB root
# 4. Boot from USB
```
diff --git a/autounattend.xml b/autounattend.xml
index 59e20f3..d92f19d 100644
--- a/autounattend.xml
+++ b/autounattend.xml
@@ -3,69 +3,23 @@
-
-
-
- en-US
-
- en-US
- en-US
- en-US
- en-US
-
-
-
-
- true
-
-
-
-
-
-
-
- 1
- reg.exe add "HKLM\SYSTEM\Setup\LabConfig" /v BypassTPMCheck /t REG_DWORD /d 1 /f
-
-
- 2
- reg.exe add "HKLM\SYSTEM\Setup\LabConfig" /v BypassSecureBootCheck /t REG_DWORD /d 1 /f
-
-
- 3
- reg.exe add "HKLM\SYSTEM\Setup\LabConfig" /v BypassRAMCheck /t REG_DWORD /d 1 /f
-
-
-
-
-
+
- true
- true
- true
3
+ true
+ false
1
- Set PowerShell Execution Policy
- powershell.exe -Command "Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force"
-
-
- 2
- Wait for Network Connectivity
- powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$timeout=300; $interval=5; $start=Get-Date; $connected=$false; while((Get-Date) -lt $start.AddSeconds($timeout)) { if (Test-Connection -ComputerName 1.1.1.1 -Count 1 -Quiet) { $connected=$true; break }; Start-Sleep -Seconds $interval }; if (-not $connected) { Write-Host 'Network wait timed out'; exit 1 }"
-
-
- 3
Run Windows Setup Bootstrap Script
powershell.exe -ExecutionPolicy Bypass -File C:\Setup\bootstrap.ps1
diff --git a/build-iso.ps1 b/build-iso.ps1
index da9d958..f6f6073 100644
--- a/build-iso.ps1
+++ b/build-iso.ps1
@@ -7,7 +7,7 @@
.DESCRIPTION
This script takes a source Windows 11 ISO and creates a customized version with:
- autounattend.xml for unattended installation
- - bootstrap.ps1, apps.json, optional-apps.json, and Sophia-Preset.ps1 copied via $OEM$ structure when present
+ - bootstrap.ps1, apps.json, optional-apps.json, and Sophia-Preset.ps1 copied via sources\$OEM$ structure when present
- Fully automated setup that runs on first login
.PARAMETER SourceISO
@@ -42,7 +42,7 @@ param(
[string]$SourceIsoHash,
[Parameter(Mandatory = $false)]
- [string]$IsoLabel = "DECLARATIVE_WIN11",
+ [string]$IsoLabel,
[Parameter(Mandatory = $false)]
[string]$OscdimgDownloadUrl,
@@ -100,6 +100,75 @@ function Validate-SourceIsoHash {
Write-Success "Source ISO checksum verified"
}
+function Get-UnattendSetupFileReferences {
+ param(
+ [Parameter(Mandatory)]
+ [string]$UnattendPath
+ )
+
+ $document = [xml](Get-Content -Path $UnattendPath -Raw)
+ $namespaceManager = [System.Xml.XmlNamespaceManager]::new($document.NameTable)
+ [void]$namespaceManager.AddNamespace('u', 'urn:schemas-microsoft-com:unattend')
+
+ $references = [System.Collections.Generic.List[string]]::new()
+ $commandNodes = $document.SelectNodes('//u:FirstLogonCommands/u:SynchronousCommand/u:CommandLine', $namespaceManager)
+
+ foreach ($commandNode in $commandNodes) {
+ foreach ($match in [regex]::Matches($commandNode.InnerText, '(?i)\bC:\\Setup\\[^\s"'';]+')) {
+ $references.Add($match.Value)
+ }
+ }
+
+ return $references | Sort-Object -Unique
+}
+
+function Validate-StagedIsoLayout {
+ param(
+ [Parameter(Mandatory)]
+ [string]$WorkRoot,
+
+ [Parameter(Mandatory)]
+ [string]$UnattendPath,
+
+ [Parameter(Mandatory)]
+ [bool]$HasOptionalApps
+ )
+
+ $stagedSetupRoot = Join-Path $WorkRoot 'sources\$OEM$\$1\Setup'
+ $requiredStagedFiles = @(
+ (Join-Path $WorkRoot 'autounattend.xml'),
+ (Join-Path $stagedSetupRoot 'bootstrap.ps1'),
+ (Join-Path $stagedSetupRoot 'apps.json'),
+ (Join-Path $stagedSetupRoot 'Sophia-Preset.ps1'),
+ (Join-Path $stagedSetupRoot 'restore-backup.ps1'),
+ (Join-Path $stagedSetupRoot 'apply-registry.ps1'),
+ (Join-Path $stagedSetupRoot 'config\registry.json'),
+ (Join-Path $stagedSetupRoot 'config\backup.template.json')
+ )
+
+ if ($HasOptionalApps) {
+ $requiredStagedFiles += Join-Path $stagedSetupRoot 'optional-apps.json'
+ }
+
+ foreach ($stagedFile in $requiredStagedFiles) {
+ if (-not (Test-Path $stagedFile -PathType Leaf)) {
+ throw "Staged ISO is missing required file: $stagedFile"
+ }
+ }
+
+ # Proven build-time check: every C:\Setup reference in unattend must resolve to the staged $OEM$ payload.
+ foreach ($setupReference in (Get-UnattendSetupFileReferences -UnattendPath $UnattendPath)) {
+ $relativePath = $setupReference.Substring('C:\Setup\'.Length)
+ $stagedReference = Join-Path $stagedSetupRoot $relativePath
+
+ if (-not (Test-Path $stagedReference -PathType Leaf)) {
+ throw "autounattend.xml references $setupReference, but staged ISO is missing $stagedReference"
+ }
+ }
+
+ Write-Success 'Staged ISO layout validation passed'
+}
+
# Function to find oscdimg.exe from Windows ADK
function Find-OscdImg {
param([string]$DownloadUrl)
@@ -254,6 +323,21 @@ try {
Write-Success "ISO mounted at: $sourceRoot"
+ # Capture the original ISO volume label for oscdimg
+ if (-not $IsoLabel) {
+ $IsoLabel = (Get-Volume -DriveLetter $driveLetter).FileSystemLabel
+ if ($IsoLabel) {
+ Write-Success "Captured original ISO label: $IsoLabel"
+ }
+ else {
+ $IsoLabel = "CCCOMA_X64FRE_EN-US_DV9"
+ Write-Info "Could not read original label, using default: $IsoLabel"
+ }
+ }
+ else {
+ Write-Info "Using user-provided ISO label: $IsoLabel"
+ }
+
# Copy ISO contents to working directory
Write-Step "Copying ISO contents (this may take a few minutes)"
Write-Info "Destination: $WorkDir"
@@ -271,14 +355,14 @@ try {
Copy-Item -Path $requiredFiles["autounattend.xml"] -Destination $WorkDir -Force
Write-Success "autounattend.xml copied to ISO root"
- # Create $OEM$ folder structure
+ # Create sources\$OEM$ folder structure
Write-Step "Creating `$OEM`$ folder structure"
- $oemPath = Join-Path $WorkDir "`$OEM`$"
+ $oemPath = Join-Path (Join-Path $WorkDir "sources") "`$OEM`$"
$setupPath = Join-Path $oemPath "`$1\Setup"
New-Item -Path $setupPath -ItemType Directory -Force | Out-Null
- Write-Success "`$OEM`$\`$1\Setup folder created"
+ Write-Success "sources\`$OEM`$\`$1\Setup folder created"
- # Copy setup files to $OEM$\$1\Setup
+ # Copy setup files to sources\$OEM$\$1\Setup
Write-Step "Copying setup files to `$OEM`$\`$1\Setup"
$filesToCopy = @(
@@ -306,18 +390,27 @@ try {
Copy-Item -Path $configSource -Destination $configDestination -Recurse -Force
Write-Success "config folder copied"
+ # Fail the build here if the staged tree does not match the paths unattend will use later.
+ Write-Step "Validating staged ISO layout"
+ Validate-StagedIsoLayout -WorkRoot $WorkDir -UnattendPath (Join-Path $WorkDir "autounattend.xml") -HasOptionalApps (Test-Path $optionalFiles["optional-apps.json"])
+
# Build the ISO
Write-Step "Building custom ISO with oscdimg"
Write-Info "This may take several minutes..."
$bootImage = Join-Path $WorkDir "boot\etfsboot.com"
- $efiBootImage = Join-Path $WorkDir "efi\microsoft\boot\efisys.bin"
+ $efiBootImage = Join-Path $WorkDir "efi\microsoft\boot\efisys_noprompt.bin"
if (-not (Test-Path $bootImage)) {
throw "BIOS boot image not found: $bootImage"
}
if (-not (Test-Path $efiBootImage)) {
- throw "UEFI boot image not found: $efiBootImage"
+ # Fall back to efisys.bin if noprompt variant is missing
+ $efiBootImage = Join-Path $WorkDir "efi\microsoft\boot\efisys.bin"
+ if (-not (Test-Path $efiBootImage)) {
+ throw "UEFI boot image not found. Neither efisys_noprompt.bin nor efisys.bin exist."
+ }
+ Write-Info "efisys_noprompt.bin not found, falling back to efisys.bin"
}
# Resolve output path
diff --git a/docs/ISO-GENERATION.md b/docs/ISO-GENERATION.md
index 7fb77be..966f5b5 100644
--- a/docs/ISO-GENERATION.md
+++ b/docs/ISO-GENERATION.md
@@ -37,28 +37,29 @@ This guide explains how to build a custom Windows 11 ISO with declarative-window
## ISO Contents
-The ISO generator injects files into the root and `$OEM$` structure:
+The ISO generator injects files into the root and `sources\$OEM$` structure:
```
Custom-ISO:\
├── autounattend.xml
-└── $OEM$\
- └── $1\
- └── Setup\
- ├── bootstrap.ps1
- ├── apps.json
- ├── optional-apps.json (optional)
- ├── Sophia-Preset.ps1
- ├── restore-backup.ps1
- ├── apply-registry.ps1
- └── config\
- ├── backup.template.json
- └── registry.json
+└── sources\
+ └── $OEM$\
+ └── $1\
+ └── Setup\
+ ├── bootstrap.ps1
+ ├── apps.json
+ ├── optional-apps.json (optional)
+ ├── Sophia-Preset.ps1
+ ├── restore-backup.ps1
+ ├── apply-registry.ps1
+ └── config\
+ ├── backup.template.json
+ └── registry.json
```
## On the Installed System
-During installation, you still choose the target disk and partition in Windows Setup. After Windows copies the `$OEM$` payload, the setup files land in `C:\Setup`.
+During installation, you still choose the target disk and partition in Windows Setup. After Windows copies the `sources\$OEM$` payload, the setup files land in `C:\Setup`.
```
C:\Setup\
diff --git a/tests/Autounattend.Tests.ps1 b/tests/Autounattend.Tests.ps1
index a8e1534..62c00b0 100644
--- a/tests/Autounattend.Tests.ps1
+++ b/tests/Autounattend.Tests.ps1
@@ -20,12 +20,10 @@ Describe "autounattend.xml static checks" {
$fileContent | Should -Not -Match ""
}
- It "sets execution policy for bootstrap" {
- $fileContent | Should -Match "Set-ExecutionPolicy"
- }
-
- It "waits for network before bootstrap" {
- $fileContent | Should -Match "Wait for Network Connectivity"
- $fileContent | Should -Match "Network wait timed out"
+ It "keeps first logon commands minimal" {
+ $fileContent | Should -Match "Run Windows Setup Bootstrap Script"
+ $fileContent | Should -Not -Match "Set-ExecutionPolicy"
+ $fileContent | Should -Not -Match "Wait for Network Connectivity"
+ $fileContent | Should -Not -Match "Network wait timed out"
}
}
diff --git a/tests/BuildIso.Tests.ps1 b/tests/BuildIso.Tests.ps1
index 3c8ca7a..556f3e3 100644
--- a/tests/BuildIso.Tests.ps1
+++ b/tests/BuildIso.Tests.ps1
@@ -5,7 +5,7 @@ Describe "build-iso.ps1 static checks" {
}
It "uses $OEM$ $1 Setup path" {
- ($scriptContent -like '*`$1\Setup*') | Should -BeTrue
+ ($scriptContent -like '*sources*`$OEM`$*`$1\Setup*') | Should -BeTrue
}
@@ -42,6 +42,30 @@ Describe "build-iso.ps1 static checks" {
$scriptContent | Should -Match "autounattend\.xml validation passed"
}
+ It "validates the staged ISO layout before oscdimg" {
+ $scriptContent | Should -Match "Validate-StagedIsoLayout"
+ $scriptContent | Should -Match "Validating staged ISO layout"
+ $scriptContent | Should -Match "Staged ISO layout validation passed"
+ }
+
+ It "checks unattend C:\\Setup references against the staged OEM payload" {
+ $stagedPathPattern = [regex]::Escape('Join-Path $WorkRoot ''sources\$OEM$\$1\Setup''')
+
+ $scriptContent | Should -Match "Get-UnattendSetupFileReferences"
+ $scriptContent | Should -Match "C:\\Setup\\"
+ $scriptContent | Should -Match $stagedPathPattern
+ $scriptContent | Should -Match "autounattend\.xml references .* staged ISO is missing"
+ }
+
+ It "runs staged layout validation before building the ISO" {
+ $validationIndex = $scriptContent.IndexOf('Validate-StagedIsoLayout')
+ $buildIndex = $scriptContent.IndexOf('Write-Step "Building custom ISO with oscdimg"')
+
+ $validationIndex | Should -BeGreaterThan -1
+ $buildIndex | Should -BeGreaterThan -1
+ $validationIndex | Should -BeLessThan $buildIndex
+ }
+
It "passes source ISO into unattend validation" {
($scriptContent -like '*-SourceISO $SourceISO*') | Should -BeTrue
}