ci(windows): implement M2 ISO build + Gitea Windows-runner workflow
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 34s
Some checks failed
Build SilverMetal Enhanced - Windows ISO / build (pull_request) Failing after 34s
Implement build.ps1 (M2): mount/extract the base ISO, offline-service install.wim (inject GPD drivers if staged, debloat appx, bake SetupComplete.cmd + hardening modules into \Windows\Setup\Scripts), inject autounattend.xml, oscdimg UEFI repack, emit SHA-256 + SBOM. Elevation + oscdimg guarded. Add .gitea/workflows/build-iso-windows.yaml: runs on the self-hosted silverlabs-runner-win (windows-latest), ensures ADK Deployment Tools, acquires the base ISO from repo var SILVERMETAL_BASE_ISO_URL or a pre-staged path, builds, validates the baked payload offline, uploads SBOM/SHA (+ISO on dispatch/tag), attaches to a Gitea release on win-v* tags. Mirrors build-iso-linux.yaml. Add tests/Assert-IsoStructure.ps1: the no-nested-virt CI gate - mounts the built ISO + install.wim read-only and asserts autounattend.xml, SetupComplete.cmd, and the hardening modules are correctly baked. Full QEMU boot+Verify is a follow-on. Switch autounattend to Windows' native SetupComplete.cmd auto-run (SYSTEM, end of setup) instead of a duplicate FirstLogonCommands call. Untested until first runner execution (dev box is ARM64). All PS parse-clean; autounattend XML + workflow YAML valid. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,22 +6,27 @@
|
||||
.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 a signed build attestation.
|
||||
UEFI-bootable ISO with an SBOM + SHA-256 attestation.
|
||||
|
||||
Design: ../iso-builder.md Controls: ../hardening-spec.md
|
||||
|
||||
Build host requirement: Windows + Windows ADK (DISM + oscdimg).
|
||||
Build host requirement: Windows x64 + Windows ADK (oscdimg) + DISM (built in).
|
||||
Must run ELEVATED (DISM image mount requires admin).
|
||||
|
||||
.NOTES
|
||||
SCAFFOLD (M0). Stage bodies are stubbed; they are fleshed out at M2.
|
||||
Each stage maps 1:1 to iso-builder.md section 3.
|
||||
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, # licensed IoT Enterprise LTSC ISO
|
||||
[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
|
||||
)
|
||||
|
||||
@@ -29,64 +34,136 @@ 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' # WIM mount point
|
||||
$null = New-Item -ItemType Directory -Force -Path $WorkDir,$mount,(Split-Path $OutputIso)
|
||||
|
||||
# --- 1. Verify input -------------------------------------------------------
|
||||
function Invoke-VerifyInput {
|
||||
Write-Stage 'Stage 1: verify input ISO against pinned manifest'
|
||||
$m = Get-Content $Manifest -Raw | ConvertFrom-Json
|
||||
$expected = $m.baseImage.isoSha256
|
||||
if ($SkipInputVerify -or $expected -like 'TODO*') {
|
||||
Write-Warning 'Input hash not pinned yet (M2). Skipping verification.'
|
||||
$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 -Path $SourceIso).Hash
|
||||
$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 work dir'
|
||||
# TODO-M2: mount $SourceIso, copy contents to $WorkDir\iso, export install.wim to $WorkDir\wim
|
||||
throw 'NotImplemented (M2): 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
|
||||
}
|
||||
|
||||
# --- 3. Service the WIM offline (DISM) -------------------------------------
|
||||
function Invoke-ServiceWim {
|
||||
Write-Stage 'Stage 3: offline-service install.wim (drivers, updates, debloat, baseline policy, Stack staging)'
|
||||
# TODO-M2: DISM /Mount-Image; /Add-Driver (windows\drivers); /Add-Package (cumulative);
|
||||
# remove appx from windows\debloat; load offline hives + apply windows\policies baseline;
|
||||
# stage Stack + $OEM$ payload; /Unmount-Image /Commit
|
||||
throw 'NotImplemented (M2): service WIM'
|
||||
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)' }
|
||||
|
||||
# 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 $WindowsDir 'hardening\*') (Join-Path $scripts 'hardening') -Recurse -Force
|
||||
} finally {
|
||||
Dismount-WindowsImage -Path $mount -Save | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# --- 4. Inject answer file + first-boot payload ----------------------------
|
||||
# --- 4. Inject answer file -------------------------------------------------
|
||||
function Invoke-InjectUnattend {
|
||||
Write-Stage 'Stage 4: inject autounattend.xml + $OEM$\SetupComplete.cmd + hardening modules'
|
||||
# TODO-M2: copy autounattend\autounattend.xml to ISO root; lay $OEM$\SetupComplete.cmd
|
||||
# + windows\hardening\* into the image so first boot can run them
|
||||
throw 'NotImplemented (M2): inject unattend'
|
||||
Write-Stage 'Stage 4: inject autounattend.xml at ISO root'
|
||||
Copy-Item (Join-Path $PSScriptRoot 'autounattend\autounattend.xml') (Join-Path $isoRoot 'autounattend.xml') -Force
|
||||
}
|
||||
|
||||
# --- 5. Brand --------------------------------------------------------------
|
||||
function Invoke-Brand {
|
||||
Write-Stage 'Stage 5: apply branding (boot/OOBE wallpaper, computer-name pattern)'
|
||||
# TODO-M4: from ..\..\shared\branding
|
||||
Write-Warning ' Branding deferred to M4.'
|
||||
}
|
||||
function Invoke-Brand { Write-Stage 'Stage 5: branding'; Write-Warning ' deferred to M4.' }
|
||||
|
||||
# --- 6. Repack -------------------------------------------------------------
|
||||
function Invoke-Repack {
|
||||
Write-Stage 'Stage 6: repack UEFI-bootable ISO via oscdimg'
|
||||
# TODO-M2: oscdimg -m -o -u2 -udfver102 -bootdata:2#... -> $OutputIso
|
||||
throw 'NotImplemented (M2): repack'
|
||||
Write-Stage 'Stage 6: repack UEFI-bootable ISO (oscdimg)'
|
||||
$etfs = Join-Path $isoRoot 'boot\etfsboot.com'
|
||||
$efi = Join-Path $isoRoot 'efi\microsoft\boot\efisys.bin'
|
||||
if (-not (Test-Path $efi)) { throw "missing UEFI boot image: $efi" }
|
||||
$bootdata = '2#p0,e,b"{0}"#pEF,e,b"{1}"' -f $etfs, $efi
|
||||
if (Test-Path $OutputIso) { Remove-Item $OutputIso -Force }
|
||||
& $oscdimg -m -o -u2 -udfver102 -l"SILVERMETAL" "-bootdata:$bootdata" $isoRoot $OutputIso
|
||||
if ($LASTEXITCODE -ne 0) { throw "oscdimg failed ($LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
# --- 7. Attest -------------------------------------------------------------
|
||||
function Invoke-Attest {
|
||||
Write-Stage 'Stage 7: emit SHA-256 + SBOM + signed build attestation'
|
||||
# TODO-M3: hash $OutputIso; build SBOM from manifest + tool versions; sign (trust-model.md)
|
||||
throw 'NotImplemented (M3): 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 -----------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user