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: Free disk space shell: pwsh run: | # Each successful build persists a ~5 GB ISO to C:\silvermetal\out, and the # boot.wim now carries WinPE-NetFx/PowerShell (bigger image). On the single-volume # runner that accumulation starves oscdimg ("Insufficient disk space"). Clear the # prior build output + any stale work dir before building so space self-heals. $before = [math]::Round((Get-PSDrive C).Free/1GB,1) Remove-Item 'C:\silvermetal\out\*' -Recurse -Force -ErrorAction SilentlyContinue Get-ChildItem 'C:\gitea-runner\workspace' -Directory -ErrorAction SilentlyContinue | ForEach-Object { $t = Join-Path $_.FullName 'tmp\smbuild' if (Test-Path $t) { Remove-Item $t -Recurse -Force -ErrorAction SilentlyContinue } } $after = [math]::Round((Get-PSDrive C).Free/1GB,1) Write-Host " C: free ${before}GB -> ${after}GB (cleared prior ISO output + stale smbuild)" - 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: Ensure Windows ADK WinPE add-on shell: pwsh run: | # build.ps1 (Invoke-ForceLegacySetup) calls Add-WindowsPackage with the # WinPE_OCs cabs, which only exist if the ADK WinPE add-on is installed. $ocs = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs' if (Test-Path $ocs) { Write-Host 'ADK WinPE add-on (WinPE_OCs) already present.'; exit 0 } Write-Host 'Installing ADK WinPE add-on...' # Prefer winget; fall back to the WinPE add-on web installer. $ok = $false try { winget install --id Microsoft.ADKPEAddon -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=2289981' -OutFile "$env:TEMP\adkwinpesetup.exe" Start-Process "$env:TEMP\adkwinpesetup.exe" -ArgumentList '/quiet','/norestart','/features','OptionId.WindowsPreinstallationEnvironment' -Wait } # The WinPE collector is a required, core feature of this image, so a missing # WinPE_OCs dir is a hard build gate (fail fast with a clear message). if (-not (Test-Path $ocs)) { throw 'ADK WinPE add-on install failed (WinPE_OCs missing)' } - name: Setup .NET 9 SDK uses: actions/setup-dotnet@v4 with: dotnet-version: '9.0.x' - name: Install MAUI workload shell: pwsh run: | Write-Host "dotnet $(dotnet --version)" Write-Host 'Installing/repairing MAUI workload (idempotent)...' dotnet workload install maui Write-Host 'MAUI workload ready.' - name: Build + test SilverOS Welcome shell: pwsh run: | dotnet test windows/welcome/SilverOS.Welcome.sln -c Release - name: Acquire base ISO id: iso shell: pwsh env: 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_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" Copy-Item -LiteralPath $staged -Destination $dst -Force } else { 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: Test branding module (Pester) shell: pwsh run: | # Windows ships Pester 3.x; force Pester 5 (the tests use v5 syntax). Set-PSRepository PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue if (-not (Get-Module -ListAvailable Pester | Where-Object { $_.Version -ge [version]'5.0.0' })) { Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -Force -SkipPublisherCheck } Get-Module Pester | Remove-Module -Force -ErrorAction SilentlyContinue Import-Module Pester -MinimumVersion 5.0 -Force Write-Host "Using Pester $((Get-Module Pester).Version)" # v5 configuration object — avoids the v3/-Output param ambiguity. $cfg = New-PesterConfiguration $cfg.Run.Path = 'windows/tests/Branding.Tests.ps1' $cfg.Run.PassThru = $true $cfg.Output.Verbosity = 'Detailed' $r = Invoke-Pester -Configuration $cfg if ($r.FailedCount -gt 0) { throw "$($r.FailedCount) branding test(s) failed" } - name: Build packed ISO shell: pwsh run: | .\windows\installer\build.ps1 ` -SourceIso '${{ steps.iso.outputs.path }}' ` -WorkDir "$env:RUNNER_TEMP\smbuild" ` -OutputIso "$env:RUNNER_TEMP\out\SilverMetal-Enhanced-Windows.iso" - name: Validate baked payload (offline assertions) shell: pwsh run: | .\windows\tests\Assert-IsoStructure.ps1 -IsoPath "$env:RUNNER_TEMP\out\SilverMetal-Enhanced-Windows.iso" - name: Persist build output to stable path shell: pwsh run: | # RUNNER_TEMP is per-job/ephemeral. Keep the latest validated build at a # stable path so it can be retrieved (e.g. for VM boot-testing) out of band. New-Item -ItemType Directory -Force 'C:\silvermetal\out' | Out-Null # The ISO is already built + validated; free the build working set (extracted ISO # tree + the mounted/expanded install.wim + the 5GB base ISO) BEFORE the ~5GB persist # copy, or the single-volume runner runs out of space mid-copy. The ISO itself lives # in RUNNER_TEMP\out (untouched) and the SBOM/SHA uploads read from there too. $before = [math]::Round((Get-PSDrive C).Free/1GB,1) Remove-Item "$env:RUNNER_TEMP\smbuild" -Recurse -Force -ErrorAction SilentlyContinue Remove-Item "$env:RUNNER_TEMP\base.iso" -Force -ErrorAction SilentlyContinue $after = [math]::Round((Get-PSDrive C).Free/1GB,1) Write-Host " freed build working set: C: ${before}GB -> ${after}GB before persist" Copy-Item "$env:RUNNER_TEMP\out\*" 'C:\silvermetal\out\' -Force Get-ChildItem 'C:\silvermetal\out' | ForEach-Object { Write-Host $_.Name } - 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)" }