Files
SilverMetal/windows/installer/build.ps1
sysadmin fce4b77bd6
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 4m8s
fix(collector): launch via Setup\CmdLine (was bypassed) + WinPE diagnostics
The boot.wim Setup\CmdLine override (legacy-Setup forcing) is authoritative over
winpeshl.ini, so it launched setup.exe directly and the collector never ran -- the
VM went straight to the old sm-bootstrap unattended install. Repoint Setup\CmdLine
at the collector (cmd /c X:\sm\Start-Collector.cmd); the collector still launches the
legacy X:\sources\setup.exe itself. Add wpeinit + an on-screen banner, and write any
collector/WinForms-load failure to X:\sm\collector-error.txt shown on the console
before falling back, so we can diagnose WinForms-in-WinPE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:14:08 +01:00

390 lines
22 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'
& 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)) {
Write-Host ' adding drivers'; Add-WindowsDriver -Path $mount -Driver $drv -Recurse | Out-Null
} 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