Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 8s
dotnet publish rejects --no-incremental (it's a dotnet build switch) -> MSB1001 Unknown switch -> build failed. The bin/obj wipe alone forces the clean recompile we need. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
402 lines
23 KiB
PowerShell
402 lines
23 KiB
PowerShell
#Requires -Version 5.1
|
|
<#
|
|
.SYNOPSIS
|
|
SilverMetal Enhanced - Windows : custom packed ISO build pipeline.
|
|
|
|
.DESCRIPTION
|
|
Turns an official Windows 11 IoT Enterprise LTSC ISO (an INPUT, never
|
|
redistributed) plus the SilverMetal config layer into a hardened, branded,
|
|
UEFI-bootable ISO with an SBOM + SHA-256 attestation.
|
|
|
|
Design: ../iso-builder.md Controls: ../hardening-spec.md
|
|
|
|
Build host requirement: Windows x64 + Windows ADK (oscdimg) + DISM (built in).
|
|
Must run ELEVATED (DISM image mount requires admin).
|
|
|
|
.NOTES
|
|
M2 implementation. Validated by running on the silverlabs-runner-win Gitea
|
|
runner (this repo cannot be built on the ARM64 dev box). Offline policy
|
|
baking is intentionally minimal here -- the §A-H hardening runs at first
|
|
boot via SetupComplete.cmd; this pipeline injects drivers, debloat, the
|
|
hardening payload, the answer file, then repacks + attests.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory)] [string] $SourceIso,
|
|
[string] $Manifest = "$PSScriptRoot\inputs.manifest.json",
|
|
[string] $WorkDir = "$env:TEMP\silvermetal-build",
|
|
[string] $OutputIso = "$PSScriptRoot\out\SilverMetal-Enhanced-Windows.iso",
|
|
[string] $WindowsDir = "$PSScriptRoot\..", # repo windows/ root
|
|
[switch] $SkipInputVerify
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
function Write-Stage { param([string]$Msg) Write-Host "==> $Msg" -ForegroundColor Cyan }
|
|
function Resolve-Tool {
|
|
param([string]$Name,[string[]]$Globs)
|
|
$c = Get-Command $Name -EA SilentlyContinue
|
|
if ($c) { return $c.Source }
|
|
foreach ($g in $Globs) { $p = Get-ChildItem $g -EA SilentlyContinue | Select-Object -First 1; if ($p) { return $p.FullName } }
|
|
throw "$Name not found. Install the Windows ADK Deployment Tools."
|
|
}
|
|
|
|
if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()
|
|
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
|
throw 'build.ps1 must run elevated (DISM image servicing requires admin).'
|
|
}
|
|
|
|
$oscdimg = Resolve-Tool 'oscdimg.exe' @(
|
|
'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\*\Oscdimg\oscdimg.exe')
|
|
$m = Get-Content $Manifest -Raw | ConvertFrom-Json
|
|
$isoRoot = Join-Path $WorkDir 'iso' # writable copy of ISO contents
|
|
$mount = Join-Path $WorkDir 'mount' # install.wim mount point
|
|
$bootmnt = Join-Path $WorkDir 'bootmnt' # boot.wim mount point
|
|
|
|
# --- 0. Discard stale state from a prior interrupted build -----------------
|
|
# An aborted run can leave a DISM image mounted (locking install.wim/boot.wim)
|
|
# or registry hives loaded, which breaks the Stage 2 extract clean-up with
|
|
# "the process cannot access the file ... because it is being used by another
|
|
# process". Discard anything of ours before (re)creating the work dirs. Match
|
|
# by 'silvermetal' so orphans from any prior WorkDir are cleaned too.
|
|
Write-Stage 'Stage 0: discard stale SilverMetal image mounts / hives from prior runs'
|
|
Get-WindowsImage -Mounted -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.ImagePath -match 'silvermetal' -or $_.MountPath -match 'silvermetal' } |
|
|
ForEach-Object {
|
|
Write-Host " discarding stale mount: $($_.MountPath)"
|
|
Dismount-WindowsImage -Path $_.MountPath -Discard -ErrorAction SilentlyContinue | Out-Null
|
|
}
|
|
foreach ($h in 'SM_BRAND_SW','SM_BRAND_DU','SM_OFFLINE','SM_BOOT') { & reg unload "HKLM\$h" 2>$null | Out-Null }
|
|
if (Test-Path $WorkDir) { Remove-Item $WorkDir -Recurse -Force -ErrorAction SilentlyContinue }
|
|
|
|
$null = New-Item -ItemType Directory -Force -Path $WorkDir,$mount,$bootmnt,(Split-Path $OutputIso)
|
|
|
|
# --- 1. Verify input -------------------------------------------------------
|
|
function Invoke-VerifyInput {
|
|
Write-Stage 'Stage 1: verify input ISO against pinned manifest'
|
|
$expected = $m.baseImage.isoSha256
|
|
if ($SkipInputVerify -or $expected -like 'TODO*') {
|
|
$actual = (Get-FileHash -Algorithm SHA256 $SourceIso).Hash
|
|
Write-Warning "Input hash not pinned. Observed SHA-256: $actual (record in inputs.manifest.json)."
|
|
return
|
|
}
|
|
$actual = (Get-FileHash -Algorithm SHA256 $SourceIso).Hash
|
|
if ($actual -ne $expected) { throw "Source ISO SHA-256 mismatch.`n expected: $expected`n actual: $actual" }
|
|
Write-Host ' ISO hash verified.'
|
|
}
|
|
|
|
# --- 2. Extract ------------------------------------------------------------
|
|
function Invoke-Extract {
|
|
Write-Stage 'Stage 2: extract ISO to writable work dir'
|
|
if (Test-Path $isoRoot) { Remove-Item $isoRoot -Recurse -Force }
|
|
$null = New-Item -ItemType Directory -Force $isoRoot
|
|
$img = Mount-DiskImage -ImagePath $SourceIso -PassThru
|
|
try {
|
|
$drive = ($img | Get-Volume).DriveLetter + ':'
|
|
Write-Host " mounted at $drive ; copying..."
|
|
Copy-Item "$drive\*" $isoRoot -Recurse -Force
|
|
} finally { Dismount-DiskImage -ImagePath $SourceIso | Out-Null }
|
|
attrib -r "$isoRoot\*" /s /d 2>$null # clear read-only carried from the ISO
|
|
}
|
|
|
|
# --- 2b. Force legacy Setup (bypass the 24H2 "ConX" front-end) -------------
|
|
function Invoke-ForceLegacySetup {
|
|
Write-Stage 'Stage 2b: force legacy Setup (patch sources\boot.wim)'
|
|
# Win11 24H2/25H2 ship a redesigned WinPE Setup front-end (setup.exe ->
|
|
# SetupHost.exe -> SetupPrep.exe) that IGNORES the windowsPE pass of
|
|
# autounattend.xml (manual language/keyboard/region prompts). No answer-file
|
|
# element toggles it; the verified fix is to point WinPE's shell at the
|
|
# LEGACY installer via HKLM\SYSTEM\Setup\CmdLine in boot.wim index 2 (the
|
|
# Setup image). The legacy engine consumes all four passes -> hands-off.
|
|
$bootwim = Join-Path $isoRoot 'sources\boot.wim'
|
|
if (-not (Test-Path $bootwim)) { throw "boot.wim not found: $bootwim" }
|
|
Mount-WindowsImage -ImagePath $bootwim -Index 2 -Path $bootmnt | Out-Null
|
|
try {
|
|
# Inject the answer file into the WinPE image at a fixed X: path, and launch
|
|
# legacy setup with an EXPLICIT /unattend -- the implicit media search is
|
|
# unreliable when setup is launched via the CmdLine override (legacy Setup
|
|
# otherwise still shows the language page).
|
|
Copy-Item (Join-Path $PSScriptRoot 'autounattend\autounattend.xml') (Join-Path $bootmnt 'autounattend.xml') -Force
|
|
# Add WinPE .NET + PowerShell so the collector (WinForms) can run in WinPE.
|
|
$adk = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs'
|
|
foreach ($oc in @('WinPE-WMI.cab','WinPE-NetFx.cab','WinPE-Scripting.cab','WinPE-PowerShell.cab')) {
|
|
$cab = Join-Path $adk $oc
|
|
if (Test-Path $cab) { Add-WindowsPackage -Path $bootmnt -PackagePath $cab | Out-Null; Write-Host " added WinPE OC: $oc" }
|
|
else { Write-Warning " WinPE OC missing on runner: $cab (collector needs the ADK WinPE add-on; boot.wim assertions will fail without it)" }
|
|
}
|
|
# Stage the collector + winpeshl so WinPE launches it instead of Setup.
|
|
$smDir = Join-Path $bootmnt 'sm'; $null = New-Item -ItemType Directory -Force $smDir
|
|
Copy-Item (Join-Path $PSScriptRoot '..\collector\*') $smDir -Recurse -Force
|
|
Copy-Item (Join-Path $smDir 'winpeshl.ini') (Join-Path $bootmnt 'Windows\System32\winpeshl.ini') -Force
|
|
Write-Host " staged collector to boot.wim \sm\ + winpeshl.ini"
|
|
# Setup\CmdLine is the WinPE setup-image shell launch and is AUTHORITATIVE over
|
|
# winpeshl.ini -- point it at the SilverMetal collector so the pre-config UI runs
|
|
# FIRST. The collector then launches the LEGACY setup.exe itself (X:\sources\setup.exe,
|
|
# preserving the legacy-Setup bypass) with its generated answer file, or falls back to
|
|
# the default autounattend.xml on cancel/error. (Pointing Setup\CmdLine straight at
|
|
# setup.exe bypassed the collector entirely -- it won over winpeshl.ini.)
|
|
$cmdline = "cmd /c X:\sm\Start-Collector.cmd"
|
|
$hive = Join-Path $bootmnt 'Windows\System32\config\SYSTEM'
|
|
& reg load 'HKLM\SM_BOOT' $hive | Out-Null
|
|
try {
|
|
& reg add 'HKLM\SM_BOOT\Setup' /v CmdLine /t REG_SZ /d $cmdline /f | Out-Null
|
|
Write-Host " WinPE Setup\CmdLine = $cmdline (legacy Setup + explicit unattend)"
|
|
} finally {
|
|
[gc]::Collect(); Start-Sleep -Seconds 2
|
|
& reg unload 'HKLM\SM_BOOT' | Out-Null
|
|
}
|
|
} finally {
|
|
Dismount-WindowsImage -Path $bootmnt -Save | Out-Null
|
|
}
|
|
}
|
|
|
|
# --- 3b. Publish Welcome app (runs before WIM mount; no mount needed) --------
|
|
function Invoke-PublishWelcome {
|
|
if ($env:SILVERMETAL_WELCOME_ENABLED -eq '0') {
|
|
Write-Host ' SILVERMETAL_WELCOME_ENABLED=0 -- skipping Welcome app publish.' -ForegroundColor Yellow
|
|
return
|
|
}
|
|
Write-Stage 'Stage 3b: publish SilverOS Welcome app (win-x64 self-contained)'
|
|
$proj = Join-Path $WindowsDir 'welcome\src\SilverOS.Welcome.App'
|
|
$out = Join-Path $WorkDir 'welcome-publish'
|
|
# Force a CLEAN compile. The CI runner reuses build artifacts across runs, and dotnet's
|
|
# incremental build has shipped a STALE SilverOS.Welcome.Core.dll (old code despite fixed
|
|
# source) -- so wipe every bin/obj under welcome/ before publishing (a clean tree forces a
|
|
# full recompile; note `dotnet publish` does NOT accept --no-incremental).
|
|
Get-ChildItem (Join-Path $WindowsDir 'welcome') -Recurse -Directory -EA SilentlyContinue |
|
|
Where-Object { $_.Name -in 'bin', 'obj' } | Remove-Item -Recurse -Force -EA SilentlyContinue
|
|
& dotnet publish $proj -c Release -f net9.0-windows10.0.19041.0 -r win-x64 --self-contained true -o $out
|
|
if ($LASTEXITCODE -ne 0) { throw 'Welcome app dotnet publish failed' }
|
|
Write-Host " Published to: $out"
|
|
}
|
|
|
|
# --- 3c. Copy Welcome payload into mounted WIM (called inside Invoke-ServiceWim) ---
|
|
function Copy-WelcomePayload {
|
|
if ($env:SILVERMETAL_WELCOME_ENABLED -eq '0') {
|
|
Write-Host ' SILVERMETAL_WELCOME_ENABLED=0 -- skipping Welcome payload copy.' -ForegroundColor Yellow
|
|
return
|
|
}
|
|
Write-Stage 'Stage 3c: stage SilverOS Welcome app + flavours into mounted image'
|
|
$out = Join-Path $WorkDir 'welcome-publish'
|
|
if (-not (Test-Path $out)) { throw "Welcome publish output not found at '$out' -- did Invoke-PublishWelcome succeed?" }
|
|
# Destination: C:\Program Files\SilverOS\Welcome (+ flavours subdir)
|
|
$dest = Join-Path $mount 'Program Files\SilverOS\Welcome'
|
|
$destFlavours = Join-Path $dest 'flavours'
|
|
$null = New-Item -ItemType Directory -Force $dest, $destFlavours
|
|
# Copy published app (SilverOS.Welcome.App.exe + all runtime deps)
|
|
Copy-Item "$out\*" $dest -Recurse -Force
|
|
# Copy flavour definitions (*.json)
|
|
$flavoursDir = Join-Path $WindowsDir 'flavours'
|
|
$flavourFiles = Get-ChildItem $flavoursDir -Filter '*.json' -EA SilentlyContinue
|
|
if ($flavourFiles) {
|
|
Copy-Item $flavourFiles.FullName $destFlavours -Force
|
|
Write-Host " Copied $($flavourFiles.Count) flavour(s) to $destFlavours"
|
|
} else {
|
|
Write-Warning " No *.json flavour files found in $flavoursDir -- image will ship with no flavours."
|
|
}
|
|
# Stage the app catalog + configure/bootstrap scripts next to the Welcome app
|
|
# (mirrors the flavours copy above): catalog.json, configure\*.ps1, bootstrap-winget.ps1.
|
|
$appsDest = Join-Path $dest 'apps'
|
|
$null = New-Item -ItemType Directory -Force $appsDest
|
|
$appsDir = Join-Path $WindowsDir 'apps'
|
|
if (Test-Path $appsDir) {
|
|
Copy-Item (Join-Path $appsDir '*') $appsDest -Recurse -Force
|
|
Write-Host " Copied app catalog + scripts to $appsDest"
|
|
} else {
|
|
Write-Warning " No apps dir found at $appsDir -- image will ship with no app catalog."
|
|
}
|
|
# Stage the fixed-version WebView2 runtime, if vendored, next to the app.
|
|
# Cold-start + air-gap: a fixed-version runtime is just files (no installer
|
|
# step at first boot) and removes the dependency on whether IoT Enterprise LTSC
|
|
# ships WebView2 at all. Operator populates windows\welcome\runtime\webview2\
|
|
# with an EXTRACTED "Microsoft Edge WebView2 Fixed Version" distribution (the
|
|
# folder that contains msedgewebview2.exe) -- handled like the drivers dir:
|
|
# absent is allowed (VM/dev test), in which case the app falls back to Evergreen.
|
|
$wv2Src = Join-Path $WindowsDir 'welcome\runtime\webview2'
|
|
if (Test-Path (Join-Path $wv2Src 'msedgewebview2.exe')) {
|
|
$wv2Dest = Join-Path $dest 'webview2'
|
|
$null = New-Item -ItemType Directory -Force $wv2Dest
|
|
Copy-Item (Join-Path $wv2Src '*') $wv2Dest -Recurse -Force
|
|
Write-Host " Staged fixed-version WebView2 runtime to $wv2Dest"
|
|
} else {
|
|
Write-Warning " No fixed-version WebView2 runtime at $wv2Src (expected msedgewebview2.exe) -- image will rely on the Evergreen runtime being present at first boot. See windows\welcome\runtime\webview2\README.md."
|
|
}
|
|
# --- Guard: verify the payload actually landed in the mounted image -------
|
|
$stagedExe = Join-Path $dest 'SilverOS.Welcome.App.exe'
|
|
if (-not (Test-Path $stagedExe)) {
|
|
throw "Welcome bake failed: SilverOS.Welcome.App.exe missing from image (expected at '$stagedExe'). Check that dotnet publish produced the exe and Copy-Item succeeded."
|
|
}
|
|
$stagedFlavours = Get-ChildItem $destFlavours -Filter '*.json' -EA SilentlyContinue
|
|
if (-not $stagedFlavours) {
|
|
throw "Welcome bake failed: no flavour manifests staged in '$destFlavours'. Add *.json files under windows/flavours/ or the installed wizard will have no flavour choices."
|
|
}
|
|
$stagedCatalog = Join-Path $appsDest 'catalog.json'
|
|
if (-not (Test-Path $stagedCatalog)) {
|
|
throw "Welcome bake failed: app catalog.json missing from image (expected at '$stagedCatalog'). Add windows/apps/catalog.json or the wizard's Apps step will be empty."
|
|
}
|
|
Write-Host " Welcome payload staged at $dest"
|
|
}
|
|
|
|
# --- 3. Service the WIM offline (DISM) -------------------------------------
|
|
function Invoke-ServiceWim {
|
|
Write-Stage 'Stage 3: offline-service install.wim'
|
|
$sources = Join-Path $isoRoot 'sources'
|
|
$wim = Join-Path $sources 'install.wim'
|
|
$esd = Join-Path $sources 'install.esd'
|
|
if (-not (Test-Path $wim) -and (Test-Path $esd)) {
|
|
Write-Host ' converting install.esd -> install.wim'
|
|
$idx = (Get-WindowsImage -ImagePath $esd | Where-Object ImageName -match 'IoT Enterprise LTSC' | Select-Object -First 1).ImageIndex
|
|
if (-not $idx) { $idx = 1 }
|
|
Export-WindowsImage -SourceImagePath $esd -SourceIndex $idx -DestinationImagePath $wim -CompressionType Max
|
|
Remove-Item $esd -Force
|
|
}
|
|
$idx = (Get-WindowsImage -ImagePath $wim | Where-Object ImageName -match 'IoT Enterprise LTSC' | Select-Object -First 1).ImageIndex
|
|
if (-not $idx) { $idx = ($m.baseImage.wimImageIndex); if (-not $idx) { $idx = 1 } }
|
|
Write-Host " servicing image index $idx"
|
|
|
|
Mount-WindowsImage -ImagePath $wim -Index $idx -Path $mount | Out-Null
|
|
try {
|
|
# Drivers (GPD Pocket 4 pack) -- skipped silently if dir empty (e.g. VM test).
|
|
$drv = Join-Path $WindowsDir 'drivers'
|
|
if ((Get-ChildItem $drv -Recurse -Filter *.inf -EA SilentlyContinue)) {
|
|
# -ForceUnsigned: skip the offline-inject signature check (the virtio NetKVM
|
|
# driver is WHQL-signed and loads fine at boot; the offline check can still
|
|
# reject it on the build host). Non-fatal: a driver issue must not brick the
|
|
# whole image build -- warn and continue without it.
|
|
Write-Host ' adding drivers'
|
|
try { Add-WindowsDriver -Path $mount -Driver $drv -Recurse -ForceUnsigned -ErrorAction Stop | Out-Null }
|
|
catch { Write-Warning " driver inject failed (continuing without it): $($_.Exception.Message)" }
|
|
} else { Write-Host ' no .inf drivers staged (ok for VM test)' }
|
|
|
|
# Kiosk features (Shell Launcher v2 + Keyboard Filter) — IoT Enterprise LTSC.
|
|
if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') {
|
|
Write-Host ' enabling Shell Launcher + Keyboard Filter features'
|
|
Enable-WindowsOptionalFeature -Path $mount -FeatureName Client-EmbeddedShellLauncher -All -NoRestart | Out-Null
|
|
Enable-WindowsOptionalFeature -Path $mount -FeatureName Client-KeyboardFilter -All -NoRestart | Out-Null
|
|
}
|
|
|
|
# Debloat: remove provisioned appx listed in debloat/appx-remove.txt (best-effort).
|
|
$list = Join-Path $WindowsDir 'debloat\appx-remove.txt'
|
|
if (Test-Path $list) {
|
|
$prov = Get-AppxProvisionedPackage -Path $mount
|
|
Get-Content $list | Where-Object { $_ -and $_ -notmatch '^\s*#' } | ForEach-Object {
|
|
$name = $_.Trim()
|
|
$prov | Where-Object DisplayName -like "$name*" | ForEach-Object {
|
|
try { Remove-AppxProvisionedPackage -Path $mount -PackageName $_.PackageName | Out-Null; Write-Host " removed $($_.DisplayName)" } catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
# Stage the hardening payload: SetupComplete.cmd (native auto-run as SYSTEM) + modules.
|
|
$scripts = Join-Path $mount 'Windows\Setup\Scripts'
|
|
$null = New-Item -ItemType Directory -Force $scripts, (Join-Path $scripts 'hardening')
|
|
Copy-Item (Join-Path $PSScriptRoot 'oem\SetupComplete.cmd') $scripts -Force
|
|
Copy-Item (Join-Path $PSScriptRoot 'oem\Configure-Kiosk.ps1') $scripts -Force
|
|
Copy-Item (Join-Path $WindowsDir 'hardening\*') (Join-Path $scripts 'hardening') -Recurse -Force
|
|
# Stage the branding module so SetupComplete.cmd can re-apply branding ONLINE
|
|
# (Windows resets the offline personalization bake during OOBE).
|
|
$brandDest = Join-Path $scripts 'branding'
|
|
$null = New-Item -ItemType Directory -Force $brandDest
|
|
Copy-Item (Join-Path $WindowsDir 'branding\*') $brandDest -Recurse -Force
|
|
|
|
# Stage Welcome app + flavours while the WIM is still mounted.
|
|
Copy-WelcomePayload
|
|
|
|
# Bake the four branding layers into the offline hives (must be inside the mount).
|
|
Write-Stage 'Stage 3d: bake SilverMetal branding (OEM/lockscreen/desktop/bitlocker)'
|
|
& (Join-Path $WindowsDir 'branding\Apply-Branding.ps1') -Mode Offline -MountPath $mount
|
|
if ($LASTEXITCODE -ne 0) { throw 'branding apply failed' }
|
|
|
|
# Bake offline UAC auto-approve policy so the Welcome wizard (launched via
|
|
# Shell Launcher v2 (Configure-Kiosk.ps1) as the sm-bootstrap shell, which
|
|
# elevates the app) silently elevates during the ephemeral sm-bootstrap
|
|
# session without a UAC prompt.
|
|
# UAC stays enabled (EnableLUA=1); the wizard's hardening re-tightens the
|
|
# policy for the daily user. Only applies when Welcome is enabled.
|
|
if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') {
|
|
Write-Stage 'Stage 3e: bake offline UAC auto-approve policy (silent elevation for sm-bootstrap)'
|
|
$hive = Join-Path $mount 'Windows\System32\config\SOFTWARE'
|
|
& reg load HKLM\SM_OFFLINE "$hive" | Out-Null
|
|
if ($LASTEXITCODE -ne 0) { throw 'reg load SOFTWARE hive failed' }
|
|
try {
|
|
& reg add 'HKLM\SM_OFFLINE\Microsoft\Windows\CurrentVersion\Policies\System' /v ConsentPromptBehaviorAdmin /t REG_DWORD /d 0 /f | Out-Null
|
|
& reg add 'HKLM\SM_OFFLINE\Microsoft\Windows\CurrentVersion\Policies\System' /v PromptOnSecureDesktop /t REG_DWORD /d 0 /f | Out-Null
|
|
Write-Host ' ConsentPromptBehaviorAdmin=0, PromptOnSecureDesktop=0 written to offline SOFTWARE hive.'
|
|
} finally {
|
|
[gc]::Collect(); Start-Sleep -Milliseconds 500
|
|
& reg unload HKLM\SM_OFFLINE | Out-Null
|
|
}
|
|
}
|
|
} finally {
|
|
Dismount-WindowsImage -Path $mount -Save | Out-Null
|
|
}
|
|
}
|
|
|
|
# --- 4. Inject answer file -------------------------------------------------
|
|
function Invoke-InjectUnattend {
|
|
Write-Stage 'Stage 4: inject autounattend.xml (ISO root + \sources)'
|
|
$src = Join-Path $PSScriptRoot 'autounattend\autounattend.xml'
|
|
Copy-Item $src (Join-Path $isoRoot 'autounattend.xml') -Force
|
|
# Also place in \sources (the implicit search location the windowsPE pass uses).
|
|
Copy-Item $src (Join-Path $isoRoot 'sources\autounattend.xml') -Force
|
|
}
|
|
|
|
# --- 5. Brand --------------------------------------------------------------
|
|
# NOTE: branding edits the OFFLINE hives, so it must run while the WIM is still
|
|
# mounted. We therefore call it from inside Invoke-ServiceWim (see Step 2), and
|
|
# this stage just asserts the staged result for the SBOM/log.
|
|
function Invoke-Brand {
|
|
Write-Stage 'Stage 5: branding (applied during WIM servicing)'
|
|
Write-Host ' branding layers baked via branding\Apply-Branding.ps1 -Mode Offline'
|
|
}
|
|
|
|
# --- 6. Repack -------------------------------------------------------------
|
|
function Invoke-Repack {
|
|
Write-Stage 'Stage 6: repack UEFI-bootable ISO (oscdimg)'
|
|
$etfs = Join-Path $isoRoot 'boot\etfsboot.com'
|
|
# Use the STANDARD prompt boot image (efisys.bin). The no-prompt variant + a
|
|
# media-first boot order causes a reinstall LOOP: every post-copy reboot
|
|
# re-boots the media before the disk install completes, so it never finishes.
|
|
# The "press any key to boot from CD/USB" prompt lets reboots fall through to
|
|
# the installed disk. (Initial media boot = one keypress or a firmware boot-menu
|
|
# selection — expected for a USB-installed SKU.)
|
|
$efi = Join-Path $isoRoot 'efi\microsoft\boot\efisys.bin'
|
|
if (-not (Test-Path $efi)) { throw "missing UEFI boot image: $efi" }
|
|
# Work paths have no spaces (SYSTEM TEMP / runner temp), so omit oscdimg's
|
|
# inner quotes around the boot images -- otherwise PowerShell mangles the
|
|
# native -bootdata arg into doubled quotes (oscdimg Error 123).
|
|
$bootdata = "2#p0,e,b$etfs#pEF,e,b$efi"
|
|
if (Test-Path $OutputIso) { Remove-Item $OutputIso -Force }
|
|
& $oscdimg -m -o -u2 -udfver102 -lSILVERMETAL "-bootdata:$bootdata" $isoRoot $OutputIso
|
|
if ($LASTEXITCODE -ne 0) { throw "oscdimg failed ($LASTEXITCODE)" }
|
|
}
|
|
|
|
# --- 7. Attest -------------------------------------------------------------
|
|
function Invoke-Attest {
|
|
Write-Stage 'Stage 7: SHA-256 + SBOM'
|
|
$hash = (Get-FileHash -Algorithm SHA256 $OutputIso).Hash
|
|
"$hash *$(Split-Path $OutputIso -Leaf)" | Set-Content "$OutputIso.sha256"
|
|
$sbom = [ordered]@{
|
|
product = $m.product
|
|
builtFrom = @{ sourceIsoSha256 = (Get-FileHash -Algorithm SHA256 $SourceIso).Hash; manifest = $m.baseImage }
|
|
output = @{ iso = (Split-Path $OutputIso -Leaf); sha256 = $hash }
|
|
tooling = @{ oscdimg = $oscdimg; dism = (Get-Command dism.exe).Version.ToString() }
|
|
note = 'Reproducibility: pinned inputs + SBOM + SHA, NOT bit-identical (iso-builder.md §5). Signing: TODO-M3.'
|
|
}
|
|
$sbom | ConvertTo-Json -Depth 6 | Set-Content "$OutputIso.sbom.json"
|
|
Write-Host " ISO SHA-256: $hash"
|
|
}
|
|
|
|
# --- orchestrate -----------------------------------------------------------
|
|
Invoke-VerifyInput
|
|
Invoke-Extract
|
|
Invoke-ForceLegacySetup
|
|
Invoke-PublishWelcome # publish Welcome app before mount (no WIM needed)
|
|
Invoke-ServiceWim # mounts WIM, stages hardening + Welcome payload, dismounts
|
|
Invoke-InjectUnattend
|
|
Invoke-Brand
|
|
Invoke-Repack
|
|
Invoke-Attest
|
|
Write-Host "`nBuild complete: $OutputIso" -ForegroundColor Green
|