Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 12 additions & 19 deletions AUTOUNATTEND.md
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Call out Windows 11 (24H2+) explicitly in the intro.

Please make the platform target explicit at the top so the doc enforces the repo policy unambiguously.

✏️ Suggested doc fix
-This file is now intentionally ultra-minimal. It avoids changing Windows Setup itself and only kicks off post-install automation after first login.
+This file is now intentionally ultra-minimal and targets **Windows 11 (24H2+) only**. It avoids changing Windows Setup itself and only kicks off post-install automation after first login.

As per coding guidelines: "**/*.{md,ps1}: Ensure all documentation, scripts, and configurations assume Windows 11 as the target platform and do not include references to Windows 10 compatibility".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
This file is now intentionally ultra-minimal. It avoids changing Windows Setup itself and only kicks off post-install automation after first login.
This file is now intentionally ultra-minimal and targets **Windows 11 (24H2+) only**. It avoids changing Windows Setup itself and only kicks off post-install automation after first login.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@AUTOUNATTEND.md` at line 3, The intro of AUTOUNATTEND.md lacks an explicit
platform target; update the top of AUTOUNATTEND.md to state the repository
policy that the document and associated scripts target Windows 11 (24H2+) only
(e.g., add a one-line header or note: "Target platform: Windows 11 (24H2+)") so
the file unambiguously enforces the Windows 11-only requirement referenced by
the guidelines; ensure this one-line callout appears at the very top of the file
and that no other lines in AUTOUNATTEND.md imply Windows 10 compatibility.


## 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)

Comment on lines 7 to 10
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix stale phase count in setup flow description.

Line 7 says “three phases,” but the updated content now documents a single unattended phase (oobeSystem). This is confusing for readers following the new model.

✏️ Suggested doc fix
-The file runs in three phases during Windows installation:
+The file now runs in a single unattended phase during Windows installation:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
The file now runs in a single unattended phase during Windows installation:
### 1. oobeSystem (First Boot)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@AUTOUNATTEND.md` around lines 7 - 10, Update the opening sentence in
AUTOUNATTEND.md that states "The file runs in three phases during Windows
installation" to correctly reflect the current content which documents only the
single unattended phase "oobeSystem"; locate the phrase near the start of the
document and change it to indicate a single phase (e.g., "The file runs in a
single phase during Windows installation: oobeSystem") and ensure any subsequent
references to multiple phases are adjusted or removed to avoid confusion about
"oobeSystem".

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
Expand All @@ -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

Expand Down
18 changes: 9 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:**
Expand All @@ -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
```
Expand Down
58 changes: 6 additions & 52 deletions autounattend.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,69 +3,23 @@
<!--
declarative-windows AutoUnattend.xml

This file intentionally keeps Windows Setup minimal and conservative.
Disk and partition selection remain manual.
Product keys and passwords are not embedded.
Ultra-minimal unattended file to avoid interfering with Windows Setup.
Windows Setup remains fully manual until first login.
Post-install automation begins only after the user completes OOBE.
-->

<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<SetupUILanguage>
<UILanguage>en-US</UILanguage>
</SetupUILanguage>
<InputLocale>en-US</InputLocale>
<SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage>
<UserLocale>en-US</UserLocale>
</component>

<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<UserData>
<AcceptEula>true</AcceptEula>
</UserData>

<!-- Leave disk and partition selection manual in Windows Setup. -->

<!-- Hardware requirement bypasses for VM compatibility. -->
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Path>reg.exe add "HKLM\SYSTEM\Setup\LabConfig" /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Path>reg.exe add "HKLM\SYSTEM\Setup\LabConfig" /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>3</Order>
<Path>reg.exe add "HKLM\SYSTEM\Setup\LabConfig" /v BypassRAMCheck /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>

<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
Comment on lines 11 to 12
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore windowsPE hardware bypass commands

Removing the entire windowsPE pass drops the LabConfig registry writes that bypass TPM/Secure Boot/RAM checks, so on unsupported hardware (especially many default VirtualBox/VMware test VMs) Windows 11 setup fails before OOBE with “This PC can’t run Windows 11.” In that scenario FirstLogonCommands never execute and bootstrap.ps1 cannot run, which regresses the repo’s VM-first installation workflow unless those bypasses are preserved or TPM-capable VMs are made an explicit requirement.

Useful? React with 👍 / 👎.

<!-- Safe standard OOBE settings kept from the Schneegans-generated baseline. -->
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<ProtectYourPC>3</ProtectYourPC>
<HideEULAPage>true</HideEULAPage>
<HideWirelessSetupInOOBE>false</HideWirelessSetupInOOBE>
</OOBE>

<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>Set PowerShell Execution Policy</Description>
<CommandLine>powershell.exe -Command "Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force"</CommandLine>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>2</Order>
<Description>Wait for Network Connectivity</Description>
<CommandLine>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 }"</CommandLine>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>3</Order>
<Description>Run Windows Setup Bootstrap Script</Description>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File C:\Setup\bootstrap.ps1</CommandLine>
</SynchronousCommand>
Expand Down
109 changes: 101 additions & 8 deletions build-iso.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,7 +42,7 @@ param(
[string]$SourceIsoHash,

[Parameter(Mandatory = $false)]
[string]$IsoLabel = "DECLARATIVE_WIN11",
[string]$IsoLabel,

[Parameter(Mandatory = $false)]
[string]$OscdimgDownloadUrl,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand All @@ -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 = @(
Expand Down Expand Up @@ -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
Expand Down
29 changes: 15 additions & 14 deletions docs/ISO-GENERATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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\
Expand Down
12 changes: 5 additions & 7 deletions tests/Autounattend.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,10 @@ Describe "autounattend.xml static checks" {
$fileContent | Should -Not -Match "<Key></Key>"
}

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"
}
}
Loading
Loading