From 1c886deca35f8c5cfec0ad9df8e903ade7f614e0 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Mon, 8 Jun 2026 18:11:05 +0100 Subject: [PATCH 1/8] ci(windows): implement M2 ISO build + Gitea Windows-runner workflow 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 --- .gitea/workflows/build-iso-windows.yaml | 131 ++++++++++++++++ windows/installer/.gitignore | 5 + .../installer/autounattend/autounattend.xml | 15 +- windows/installer/build.ps1 | 141 ++++++++++++++---- windows/tests/Assert-IsoStructure.ps1 | 44 ++++++ 5 files changed, 297 insertions(+), 39 deletions(-) create mode 100644 .gitea/workflows/build-iso-windows.yaml create mode 100644 windows/installer/.gitignore create mode 100644 windows/tests/Assert-IsoStructure.ps1 diff --git a/.gitea/workflows/build-iso-windows.yaml b/.gitea/workflows/build-iso-windows.yaml new file mode 100644 index 0000000..a02a4d9 --- /dev/null +++ b/.gitea/workflows/build-iso-windows.yaml @@ -0,0 +1,131 @@ +name: Build SilverMetal Enhanced - Windows ISO + +# M2/M3. Builds the custom packed ISO on the self-hosted Windows runner +# (silverlabs-runner-win, labels windows-latest / windows-2025), validates the +# baked payload offline (no nested-virt needed), and on a tag attaches the ISO +# + SBOM + SHA256 to a Gitea release. Mirrors build-iso-linux.yaml's structure. +# +# Base ISO: the licensed/eval Windows 11 IoT Enterprise LTSC media is an INPUT, +# never committed. Provide it via the repo variable SILVERMETAL_BASE_ISO_URL +# (downloaded at build time) OR pre-stage it on the runner at C:\silvermetal\base.iso. +# +# Reproducibility scope (iso-builder.md §5): pinned inputs + SBOM + SHA, NOT +# bit-identical on Windows -- so this is single-build, not the Linux double-build gate. + +on: + push: + branches: [main] + paths: + - 'windows/**' + - '.gitea/workflows/build-iso-windows.yaml' + tags: + - 'win-v*' + pull_request: + branches: [main] + paths: + - 'windows/**' + - '.gitea/workflows/build-iso-windows.yaml' + workflow_dispatch: + +concurrency: + group: build-iso-windows-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: windows-latest + timeout-minutes: 120 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Ensure Windows ADK (oscdimg) + shell: pwsh + run: | + $deploy = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools' + if ((Get-Command oscdimg.exe -EA SilentlyContinue) -or (Test-Path $deploy)) { + Write-Host 'ADK Deployment Tools already present.'; exit 0 + } + Write-Host 'Installing ADK Deployment Tools...' + # Prefer winget; fall back to the ADK web installer. + $ok = $false + try { winget install --id Microsoft.WindowsADK -e --accept-source-agreements --accept-package-agreements --silent; $ok = $true } catch {} + if (-not $ok) { + # NOTE: fwlink id is ADK-version-specific; update if the channel rolls. + Invoke-WebRequest 'https://go.microsoft.com/fwlink/?linkid=2289980' -OutFile "$env:TEMP\adksetup.exe" + Start-Process "$env:TEMP\adksetup.exe" -ArgumentList '/quiet','/norestart','/features','OptionId.DeploymentTools' -Wait + } + if (-not (Test-Path $deploy)) { throw 'ADK Deployment Tools install failed.' } + + - name: Acquire base ISO + id: iso + shell: pwsh + env: + ISO_URL: ${{ vars.SILVERMETAL_BASE_ISO_URL }} + run: | + $staged = 'C:\silvermetal\base.iso' + if ($env:ISO_URL) { + $dst = "$env:RUNNER_TEMP\base.iso" + Write-Host "Downloading base ISO from repo variable URL..." + Invoke-WebRequest $env:ISO_URL -OutFile $dst + "path=$dst" >> $env:GITHUB_OUTPUT + } elseif (Test-Path $staged) { + Write-Host "Using pre-staged ISO: $staged" + "path=$staged" >> $env:GITHUB_OUTPUT + } else { + throw "No base ISO. Set repo variable SILVERMETAL_BASE_ISO_URL or stage it at $staged on the runner." + } + + - name: Build packed ISO + shell: pwsh + run: | + .\windows\installer\build.ps1 ` + -SourceIso '${{ steps.iso.outputs.path }}' ` + -OutputIso "$env:RUNNER_TEMP\out\SilverMetal-Enhanced-Windows.iso" ` + -SkipInputVerify + + - name: Validate baked payload (offline assertions) + shell: pwsh + run: | + .\windows\tests\Assert-IsoStructure.ps1 -IsoPath "$env:RUNNER_TEMP\out\SilverMetal-Enhanced-Windows.iso" + + - name: Upload SBOM + SHA (always) + uses: actions/upload-artifact@v3 + with: + name: silvermetal-windows-attestation-${{ github.run_id }} + path: | + ${{ runner.temp }}/out/*.sbom.json + ${{ runner.temp }}/out/*.sha256 + if-no-files-found: warn + retention-days: 30 + + - name: Upload ISO (dispatch / tag only) + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@v3 + with: + name: silvermetal-windows-iso-${{ github.run_id }} + path: ${{ runner.temp }}/out/*.iso + if-no-files-found: error + retention-days: 14 + + - name: Attach to Gitea release (tag only) + if: startsWith(github.ref, 'refs/tags/') + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $api = "${{ github.server_url }}/api/v1" + $repo = "${{ github.repository }}" + $tag = "${{ github.ref_name }}" + $h = @{ Authorization = "token $env:GITHUB_TOKEN" } + $rel = try { Invoke-RestMethod -Headers $h "$api/repos/$repo/releases/tags/$tag" } catch { $null } + if (-not $rel) { + $body = @{ tag_name=$tag; name="SilverMetal Enhanced - Windows $tag"; body="Packed ISO + SBOM + SHA256. Reproducibility: pinned inputs + SBOM (not bit-identical)."; draft=$false; prerelease=$true } | ConvertTo-Json + $rel = Invoke-RestMethod -Method Post -Headers $h -ContentType 'application/json' -Body $body "$api/repos/$repo/releases" + } + Get-ChildItem "$env:RUNNER_TEMP\out\*" -Include *.iso,*.sbom.json,*.sha256 | ForEach-Object { + Write-Host "Attaching $($_.Name)" + Invoke-RestMethod -Method Post -Headers $h -ContentType 'application/octet-stream' ` + -InFile $_.FullName "$api/repos/$repo/releases/$($rel.id)/assets?name=$($_.Name)" + } diff --git a/windows/installer/.gitignore b/windows/installer/.gitignore new file mode 100644 index 0000000..80be080 --- /dev/null +++ b/windows/installer/.gitignore @@ -0,0 +1,5 @@ +# Build outputs — never commit packed ISOs or attestations. +out/ +*.iso +*.iso.sha256 +*.iso.sbom.json diff --git a/windows/installer/autounattend/autounattend.xml b/windows/installer/autounattend/autounattend.xml index 29d92ef..af356e5 100644 --- a/windows/installer/autounattend/autounattend.xml +++ b/windows/installer/autounattend/autounattend.xml @@ -52,13 +52,14 @@ - - - 1 - cmd /c C:\Windows\Setup\Scripts\SetupComplete.cmd - SilverMetal first-boot hardening - - + + SilverMetal + SilverLABS diff --git a/windows/installer/build.ps1 b/windows/installer/build.ps1 index 3612fcf..32499ce 100644 --- a/windows/installer/build.ps1 +++ b/windows/installer/build.ps1 @@ -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 ----------------------------------------------------------- diff --git a/windows/tests/Assert-IsoStructure.ps1 b/windows/tests/Assert-IsoStructure.ps1 new file mode 100644 index 0000000..e958978 --- /dev/null +++ b/windows/tests/Assert-IsoStructure.ps1 @@ -0,0 +1,44 @@ +#Requires -Version 5.1 +<# SilverMetal Enhanced - Windows | Offline ISO structure assertions. + The reliable CI gate that needs NO nested virtualization: proves the built + ISO baked the answer file + hardening payload correctly. Full boot-and-Verify + (QEMU + OVMF + swtpm) is a follow-on stage that needs nested virt. + Exit 0 = all assertions pass; non-zero = failures. +#> +[CmdletBinding()] param([Parameter(Mandatory)][string]$IsoPath) +Set-StrictMode -Version Latest; $ErrorActionPreference = 'Stop' + +$fail = 0 +function Assert { param([string]$Name,[bool]$Cond) + if ($Cond) { Write-Host "[PASS] $Name" -ForegroundColor Green } + else { Write-Host "[FAIL] $Name" -ForegroundColor Red; $script:fail++ } +} + +if (-not (([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator))) { throw 'must run elevated (WIM mount).' } + +$img = Mount-DiskImage -ImagePath $IsoPath -PassThru +$mount = Join-Path $env:TEMP 'sm-assert-wim' +$null = New-Item -ItemType Directory -Force $mount +try { + $drive = ($img | Get-Volume).DriveLetter + ':' + Assert 'autounattend.xml at ISO root' (Test-Path "$drive\autounattend.xml") + $wim = "$drive\sources\install.wim" + Assert 'sources\install.wim present' (Test-Path $wim) + + if (Test-Path $wim) { + $idx = (Get-WindowsImage -ImagePath $wim | Where-Object ImageName -match 'IoT Enterprise LTSC' | Select-Object -First 1).ImageIndex + if (-not $idx) { $idx = 1 } + Mount-WindowsImage -ImagePath $wim -Index $idx -Path $mount -ReadOnly | Out-Null + try { + $sc = Join-Path $mount 'Windows\Setup\Scripts\SetupComplete.cmd' + Assert 'SetupComplete.cmd baked into WIM' (Test-Path $sc) + $mods = Get-ChildItem (Join-Path $mount 'Windows\Setup\Scripts\hardening') -Filter *.ps1 -EA SilentlyContinue + Assert 'hardening modules baked (>=9 .ps1)' ($mods.Count -ge 9) + Assert 'Verify script baked' (Test-Path (Join-Path $mount 'Windows\Setup\Scripts\hardening\Verify-SilverMetalWindows.ps1')) + } finally { Dismount-WindowsImage -Path $mount -Discard | Out-Null } + } +} finally { Dismount-DiskImage -ImagePath $IsoPath | Out-Null } + +Write-Host "`n$($fail) assertion(s) failed." +exit $fail -- 2.39.5 From 5e42da619e7a6958f982a35288f9d94d38cae1c7 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Mon, 8 Jun 2026 19:19:40 +0100 Subject: [PATCH 2/8] ci(windows): make base-ISO acquire step path-aware (UNC/local + optional SMB creds) SILVERMETAL_BASE_ISO_URL now accepts an HTTP(S) URL or a UNC/local path. For a UNC share that the SYSTEM-context runner can't read anonymously, optional repo secrets SILVERMETAL_ISO_SHARE_USER/_PASS map the share root via net use first. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/build-iso-windows.yaml | 29 ++++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/build-iso-windows.yaml b/.gitea/workflows/build-iso-windows.yaml index a02a4d9..101a581 100644 --- a/.gitea/workflows/build-iso-windows.yaml +++ b/.gitea/workflows/build-iso-windows.yaml @@ -62,20 +62,33 @@ jobs: id: iso shell: pwsh env: - ISO_URL: ${{ vars.SILVERMETAL_BASE_ISO_URL }} + ISO_SRC: ${{ vars.SILVERMETAL_BASE_ISO_URL }} # HTTP(S) URL or UNC/local path + SMB_USER: ${{ secrets.SILVERMETAL_ISO_SHARE_USER }} + SMB_PASS: ${{ secrets.SILVERMETAL_ISO_SHARE_PASS }} run: | + $dst = "$env:RUNNER_TEMP\base.iso" $staged = 'C:\silvermetal\base.iso' - if ($env:ISO_URL) { - $dst = "$env:RUNNER_TEMP\base.iso" - Write-Host "Downloading base ISO from repo variable URL..." - Invoke-WebRequest $env:ISO_URL -OutFile $dst - "path=$dst" >> $env:GITHUB_OUTPUT + if ($env:ISO_SRC -match '^(?i)https?://') { + Write-Host 'Downloading base ISO (HTTP)...' + Invoke-WebRequest $env:ISO_SRC -OutFile $dst + } elseif ($env:ISO_SRC) { + # UNC or local path. The runner is SYSTEM; if the share needs auth, + # provide repo secrets SILVERMETAL_ISO_SHARE_USER/_PASS and we map it. + if ($env:ISO_SRC -like '\\*' -and $env:SMB_USER) { + $root = [regex]::Match($env:ISO_SRC, '^\\\\[^\\]+\\[^\\]+').Value + Write-Host "Authenticating to $root" + cmd /c "net use `"$root`" /user:$env:SMB_USER $env:SMB_PASS" | Out-Null + } + if (-not (Test-Path -LiteralPath $env:ISO_SRC)) { throw "ISO path not reachable by the runner (SYSTEM): $env:ISO_SRC" } + Write-Host 'Copying base ISO from path...' + Copy-Item -LiteralPath $env:ISO_SRC -Destination $dst -Force } elseif (Test-Path $staged) { Write-Host "Using pre-staged ISO: $staged" - "path=$staged" >> $env:GITHUB_OUTPUT + Copy-Item -LiteralPath $staged -Destination $dst -Force } else { - throw "No base ISO. Set repo variable SILVERMETAL_BASE_ISO_URL or stage it at $staged on the runner." + throw "No base ISO. Set repo variable SILVERMETAL_BASE_ISO_URL (URL or UNC/local path) or stage it at $staged." } + "path=$dst" >> $env:GITHUB_OUTPUT - name: Build packed ISO shell: pwsh -- 2.39.5 From cc016750569c977e059865756fc2523d4a0e2214 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Mon, 8 Jun 2026 19:33:37 +0100 Subject: [PATCH 3/8] ci: add throwaway runner-probe workflow to discover runner topology Temporary diagnostic to see the silverlabs-runner-win host identity, drives, share mounts/stored creds, and ISO reachability before wiring the base-ISO source. Removed once the source is settled. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/runner-probe.yaml | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .gitea/workflows/runner-probe.yaml diff --git a/.gitea/workflows/runner-probe.yaml b/.gitea/workflows/runner-probe.yaml new file mode 100644 index 0000000..dd96f65 --- /dev/null +++ b/.gitea/workflows/runner-probe.yaml @@ -0,0 +1,35 @@ +name: Runner probe (throwaway) + +# Temporary diagnostic: discover the silverlabs-runner-win host identity, drives, +# existing share mounts/stored creds, and whether the base ISO is reachable - +# so we can pick the simplest path (local path vs persistent SMB mount). Delete +# once the ISO source is wired. workflow_dispatch only. + +on: + workflow_dispatch: + +jobs: + probe: + runs-on: windows-latest + timeout-minutes: 5 + steps: + - name: Probe + shell: pwsh + env: + ISO_SRC: ${{ vars.SILVERMETAL_BASE_ISO_URL }} + run: | + Write-Host "host=$env:COMPUTERNAME user=$(whoami)" + Write-Host "--- IPv4 (is the runner itself 10.0.0.60?) ---" + (Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -ne '127.0.0.1' }).IPAddress + Write-Host "--- local filesystem drives ---" + Get-PSDrive -PSProvider FileSystem | Format-Table Name, @{n='UsedGB';e={[math]::Round($_.Used/1GB,1)}}, @{n='FreeGB';e={[math]::Round($_.Free/1GB,1)}}, Root -AutoSize + Write-Host "--- existing net use mappings ---"; cmd /c "net use" + Write-Host "--- stored credentials (cmdkey) ---"; cmd /c "cmdkey /list" + Write-Host "--- UNC reachability as SYSTEM ---" + Write-Host ("UNC exists = {0}" -f [bool](Test-Path -LiteralPath $env:ISO_SRC)) + Write-Host "--- top-level dirs on each drive matching ISO/private/SILVERLABS ---" + Get-PSDrive -PSProvider FileSystem | ForEach-Object { + Get-ChildItem $_.Root -Directory -EA SilentlyContinue | + Where-Object Name -match 'ISO|private|SILVERLABS|template' | + Select-Object -ExpandProperty FullName + } -- 2.39.5 From 78d4d84f881a4b5c0b29eda61d9703c5ceeaeb22 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Mon, 8 Jun 2026 19:47:56 +0100 Subject: [PATCH 4/8] ci: runner-prep workflow (extend C: only); drop in-CI ISO staging Master creds must not live in this public repo's Actions, so ISO staging is handled out-of-band. runner-prep now only extends C: into the resized virtual disk. Quoted the step name (trailing-colon YAML fix). Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/runner-prep.yaml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .gitea/workflows/runner-prep.yaml diff --git a/.gitea/workflows/runner-prep.yaml b/.gitea/workflows/runner-prep.yaml new file mode 100644 index 0000000..695c7bc --- /dev/null +++ b/.gitea/workflows/runner-prep.yaml @@ -0,0 +1,26 @@ +name: Runner prep (throwaway) + +# One-time prep on silverlabs-runner-win (GITEA-RUN-WIN, SYSTEM): extend C: into +# the newly-added virtual-disk space (Proxmox resize already done host-side). +# ISO staging is handled separately (creds must not live in this public repo's CI). +# Delete this workflow once prep is done. workflow_dispatch only. + +on: + workflow_dispatch: + +jobs: + prep: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - name: "Extend C: into added space" + shell: pwsh + run: | + 'rescan' | diskpart | Out-Null + $max = (Get-PartitionSupportedSize -DriveLetter C).SizeMax + $cur = (Get-Partition -DriveLetter C).Size + if ($cur -lt ($max - 1GB)) { + Write-Host ("Extending C: {0} GB -> {1} GB" -f [math]::Round($cur/1GB,1), [math]::Round($max/1GB,1)) + Resize-Partition -DriveLetter C -Size $max + } else { Write-Host 'C: already at max; nothing to extend.' } + Get-PSDrive C | ForEach-Object { Write-Host ("C: free now {0} GB" -f [math]::Round($_.Free/1GB,1)) } -- 2.39.5 From ee34b8e37399b52a574cf53b86c462c90afc6156 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Mon, 8 Jun 2026 20:54:33 +0100 Subject: [PATCH 5/8] ci: probe credential-less net use as SYSTEM (stored cmdkey) --- .gitea/workflows/runner-probe.yaml | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/.gitea/workflows/runner-probe.yaml b/.gitea/workflows/runner-probe.yaml index dd96f65..c1f2585 100644 --- a/.gitea/workflows/runner-probe.yaml +++ b/.gitea/workflows/runner-probe.yaml @@ -13,23 +13,15 @@ jobs: runs-on: windows-latest timeout-minutes: 5 steps: - - name: Probe + - name: Probe SMB as SYSTEM (credential-less net use via stored cmdkey) shell: pwsh env: ISO_SRC: ${{ vars.SILVERMETAL_BASE_ISO_URL }} run: | - Write-Host "host=$env:COMPUTERNAME user=$(whoami)" - Write-Host "--- IPv4 (is the runner itself 10.0.0.60?) ---" - (Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -ne '127.0.0.1' }).IPAddress - Write-Host "--- local filesystem drives ---" - Get-PSDrive -PSProvider FileSystem | Format-Table Name, @{n='UsedGB';e={[math]::Round($_.Used/1GB,1)}}, @{n='FreeGB';e={[math]::Round($_.Free/1GB,1)}}, Root -AutoSize - Write-Host "--- existing net use mappings ---"; cmd /c "net use" - Write-Host "--- stored credentials (cmdkey) ---"; cmd /c "cmdkey /list" - Write-Host "--- UNC reachability as SYSTEM ---" - Write-Host ("UNC exists = {0}" -f [bool](Test-Path -LiteralPath $env:ISO_SRC)) - Write-Host "--- top-level dirs on each drive matching ISO/private/SILVERLABS ---" - Get-PSDrive -PSProvider FileSystem | ForEach-Object { - Get-ChildItem $_.Root -Directory -EA SilentlyContinue | - Where-Object Name -match 'ISO|private|SILVERLABS|template' | - Select-Object -ExpandProperty FullName - } + Write-Host "user=$(whoami)" + $root = [regex]::Match($env:ISO_SRC, '^\\\\[^\\]+\\[^\\]+').Value + Write-Host "--- credential-less net use $root (should consume stored SYSTEM cmdkey) ---" + $r = cmd /c "net use `"$root`"" 2>&1 | Out-String + Write-Host $r + Write-Host ("UNC exists after net use = {0}" -f [bool](Test-Path -LiteralPath $env:ISO_SRC)) + cmd /c "net use `"$root`" /delete /y" 2>&1 | Out-Null -- 2.39.5 From 3effd5e33889ad4241fd2d6a01786a890a712caa Mon Sep 17 00:00:00 2001 From: sysadmin Date: Mon, 8 Jun 2026 20:58:07 +0100 Subject: [PATCH 6/8] ci(windows): pin base-ISO SHA + verify; ISO staged locally on runner Base eval ISO staged at C:\silvermetal\base.iso on GITEA-RUN-WIN (SHA256 2CEE70BD...CB29 pinned in inputs.manifest.json). Repo var now points at that local path, so the build reads locally - no NAS share auth / no CI creds. Dropped -SkipInputVerify so the build verifies the pinned hash. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/build-iso-windows.yaml | 3 +-- windows/installer/inputs.manifest.json | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/build-iso-windows.yaml b/.gitea/workflows/build-iso-windows.yaml index 101a581..07545bf 100644 --- a/.gitea/workflows/build-iso-windows.yaml +++ b/.gitea/workflows/build-iso-windows.yaml @@ -95,8 +95,7 @@ jobs: run: | .\windows\installer\build.ps1 ` -SourceIso '${{ steps.iso.outputs.path }}' ` - -OutputIso "$env:RUNNER_TEMP\out\SilverMetal-Enhanced-Windows.iso" ` - -SkipInputVerify + -OutputIso "$env:RUNNER_TEMP\out\SilverMetal-Enhanced-Windows.iso" - name: Validate baked payload (offline assertions) shell: pwsh diff --git a/windows/installer/inputs.manifest.json b/windows/installer/inputs.manifest.json index 90d8317..4b03910 100644 --- a/windows/installer/inputs.manifest.json +++ b/windows/installer/inputs.manifest.json @@ -6,7 +6,7 @@ "baseImage": { "edition": "Windows 11 IoT Enterprise LTSC", "arch": "x64", - "isoSha256": "TODO-M2-pin-against-licensed-media", + "isoSha256": "2CEE70BD183DF42B92A2E0DA08CC2BB7A2A9CE3A3841955A012C0F77AEB3CB29", "wimImageName": "Windows 11 IoT Enterprise LTSC", "wimImageIndex": null }, -- 2.39.5 From 5dbbaaf22c4ca3d3399fa2bdabeeb6651e7db50b Mon Sep 17 00:00:00 2001 From: sysadmin Date: Mon, 8 Jun 2026 21:08:33 +0100 Subject: [PATCH 7/8] fix(windows/build): drop oscdimg -bootdata inner quotes (PS arg mangling) Stages 1-5 pass; oscdimg failed with Error 123 because PowerShell doubled the embedded quotes in -bootdata. Work paths have no spaces, so omit the inner quotes around etfsboot.com/efisys.bin entirely. Co-Authored-By: Claude Opus 4.8 --- windows/installer/build.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/windows/installer/build.ps1 b/windows/installer/build.ps1 index 32499ce..eb151b6 100644 --- a/windows/installer/build.ps1 +++ b/windows/installer/build.ps1 @@ -144,9 +144,12 @@ function Invoke-Repack { $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 + # 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 -l"SILVERMETAL" "-bootdata:$bootdata" $isoRoot $OutputIso + & $oscdimg -m -o -u2 -udfver102 -lSILVERMETAL "-bootdata:$bootdata" $isoRoot $OutputIso if ($LASTEXITCODE -ne 0) { throw "oscdimg failed ($LASTEXITCODE)" } } -- 2.39.5 From 6d23a892b9765e3caee7abb404150c0f46a2960e Mon Sep 17 00:00:00 2001 From: sysadmin Date: Mon, 8 Jun 2026 21:13:06 +0100 Subject: [PATCH 8/8] ci: remove throwaway runner-probe/runner-prep diagnostics Their job is done (runner topology mapped, C: extended, ISO staged). The build + offline-validation pipeline is green on the runner. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/runner-prep.yaml | 26 -------------------------- .gitea/workflows/runner-probe.yaml | 27 --------------------------- 2 files changed, 53 deletions(-) delete mode 100644 .gitea/workflows/runner-prep.yaml delete mode 100644 .gitea/workflows/runner-probe.yaml diff --git a/.gitea/workflows/runner-prep.yaml b/.gitea/workflows/runner-prep.yaml deleted file mode 100644 index 695c7bc..0000000 --- a/.gitea/workflows/runner-prep.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: Runner prep (throwaway) - -# One-time prep on silverlabs-runner-win (GITEA-RUN-WIN, SYSTEM): extend C: into -# the newly-added virtual-disk space (Proxmox resize already done host-side). -# ISO staging is handled separately (creds must not live in this public repo's CI). -# Delete this workflow once prep is done. workflow_dispatch only. - -on: - workflow_dispatch: - -jobs: - prep: - runs-on: windows-latest - timeout-minutes: 15 - steps: - - name: "Extend C: into added space" - shell: pwsh - run: | - 'rescan' | diskpart | Out-Null - $max = (Get-PartitionSupportedSize -DriveLetter C).SizeMax - $cur = (Get-Partition -DriveLetter C).Size - if ($cur -lt ($max - 1GB)) { - Write-Host ("Extending C: {0} GB -> {1} GB" -f [math]::Round($cur/1GB,1), [math]::Round($max/1GB,1)) - Resize-Partition -DriveLetter C -Size $max - } else { Write-Host 'C: already at max; nothing to extend.' } - Get-PSDrive C | ForEach-Object { Write-Host ("C: free now {0} GB" -f [math]::Round($_.Free/1GB,1)) } diff --git a/.gitea/workflows/runner-probe.yaml b/.gitea/workflows/runner-probe.yaml deleted file mode 100644 index c1f2585..0000000 --- a/.gitea/workflows/runner-probe.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: Runner probe (throwaway) - -# Temporary diagnostic: discover the silverlabs-runner-win host identity, drives, -# existing share mounts/stored creds, and whether the base ISO is reachable - -# so we can pick the simplest path (local path vs persistent SMB mount). Delete -# once the ISO source is wired. workflow_dispatch only. - -on: - workflow_dispatch: - -jobs: - probe: - runs-on: windows-latest - timeout-minutes: 5 - steps: - - name: Probe SMB as SYSTEM (credential-less net use via stored cmdkey) - shell: pwsh - env: - ISO_SRC: ${{ vars.SILVERMETAL_BASE_ISO_URL }} - run: | - Write-Host "user=$(whoami)" - $root = [regex]::Match($env:ISO_SRC, '^\\\\[^\\]+\\[^\\]+').Value - Write-Host "--- credential-less net use $root (should consume stored SYSTEM cmdkey) ---" - $r = cmd /c "net use `"$root`"" 2>&1 | Out-String - Write-Host $r - Write-Host ("UNC exists after net use = {0}" -f [bool](Test-Path -LiteralPath $env:ISO_SRC)) - cmd /c "net use `"$root`" /delete /y" 2>&1 | Out-Null -- 2.39.5