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)"
          }
