# Build the punktfunk Windows HOST as a signed Inno Setup installer and publish it to Gitea's generic
# package registry, so a Windows GPU box can install the streaming host (SYSTEM service + bundled
# pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script runner,
# run by scheduled tasks on a bundled bun) from one signed setup.exe. Runs on a self-hosted
# windows-amd64 runner
# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's
# windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below).
#
# Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that
# CreateProcessAsUserW's into the interactive session for secure-desktop capture, and bundles a
# kernel/IDD driver — neither is expressible in MSIX's sandbox. The real install logic already lives
# in `punktfunk-host service install` (crates/punktfunk-host/src/service.rs); the installer just lays
# the exe down and calls it elevated. Packaging internals: packaging/windows/README.md.
#
# Registry (public reads, unom org): https://git.unom.io/unom/-/packages  (generic group)
#
# Versioning (free-form; not MSIX's 4-part rule) — single project version:
#   vX.Y.Z tag -> X.Y.Z   (THE release; published + stable `latest/` alias + attached to the
#                          unified Gitea Release).
#   main push / dispatch -> <next-minor>.<run_number>  (canary; `canary/` alias; base one minor
#                          ahead of the latest stable tag via scripts/ci/pf-version.ps1, run climbs).
#
# Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them
# an ephemeral self-signed cert is generated and its public .cer published next to the installer
# (import once to LocalMachine\TrustedPublisher). See packaging/windows/pack-host-installer.ps1.
#
# GPU backends: the host builds with --features nvenc,amf-qsv,qsv = all three vendors in one installer.
#  - NVENC (NVIDIA, direct SDK): nothing needed at build time — the entry points are resolved at
#    RUNTIME from the driver's nvEncodeAPI64.dll (a link-time import would kill the binary on
#    AMD/Intel-only boxes before main).
#  - QSV native (Intel, VPL — design/native-qsv-encoder.md): the MIT dispatcher is built from the
#    vendored tree (libvpl-sys, cmake+bindgen — LIBCLANG_PATH already in the runner env for
#    pyrowave-sys) and statically linked; the GPU runtime comes from the Intel driver store at run
#    time, so no new DLL ships and non-Intel boxes are unaffected. This is the Intel dispatch;
#    the ffmpeg *_qsv path below stays as its open-failure fallback until Phase 4 deletes it.
#  - AMF/QSV (AMD/Intel, libavcodec): link-imports the FFmpeg libs from FFMPEG_DIR (the BtbN lgpl-shared
#    tree the client uses; includes the *_amf/*_qsv encoders) and bundles its DLLs into the installer.
#    lgpl-shared (not gpl-shared) keeps those bundled DLLs LGPL (we never use the GPL-only x264/x265).
# CI never launches the exe, so no GPU is needed here — this is build + Windows clippy coverage only.
name: windows-host

on:
  push:
    branches: [main]
    paths:
      - 'crates/punktfunk-host/**'
      - 'crates/punktfunk-core/**'
      - 'crates/punktfunk-tray/**'
      # The encode subsystem (split out in W6) + the vendored VPL dispatcher the `qsv` feature
      # builds — without these, encoder changes only reached this workflow via Cargo.lock luck.
      - 'crates/pf-encode/**'
      - 'crates/libvpl-sys/**'
      # …and the rest of the W6 subsystem crates this build compiles. pf-encode was listed while
      # the crates it speaks (pf-frame's CapturedFrame/PixelFormat/dxgi vocabulary, pf-gpu's
      # adapter selection, pf-zerocopy, pf-host-config) were not, so a change that broke the
      # Windows host through one of THEM reached main with no Windows build at all — the same
      # Cargo.lock-luck gap the two lines above were added to close.
      - 'crates/pf-frame/**'
      - 'crates/pf-gpu/**'
      - 'crates/pf-zerocopy/**'
      - 'crates/pf-host-config/**'
      - 'crates/pf-capture/**'
      - 'crates/pf-win-display/**'
      - 'crates/pf-vdisplay/**'
      - 'crates/pf-inject/**'
      - 'crates/pf-paths/**'
      - 'crates/pf-driver-proto/**'
      - 'crates/pf-clipboard/**'
      - 'crates/pyrowave-sys/**'
      - 'packaging/windows/**'
      - 'scripts/windows/**'
      - 'web/**'
      - 'sdk/**'
      - 'Cargo.lock'
      - 'Cargo.toml'
      - '.gitea/workflows/windows-host.yml'
    tags: ['v*']
  workflow_dispatch:

env:
  REGISTRY: git.unom.io
  OWNER: unom
  PKG: punktfunk-host-windows

jobs:
  package:
    runs-on: windows-amd64
    timeout-minutes: 90
    steps:
      - uses: actions/checkout@v4

      - name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target)
        shell: pwsh
        run: ./scripts/ci/ensure-windows-toolchain.ps1

      - name: Locale-safety gate (installer-run scripts must be ASCII)
        shell: pwsh
        # The installer runs these via powershell.exe (Windows PowerShell 5.1) and cmd.exe on the END
        # USER's box. PS 5.1 reads a BOM-less script in the active ANSI codepage, so on a non-UTF-8 locale
        # (e.g. German Windows-1252) a stray em-dash mis-decodes into a curly quote and the script aborts
        # with "unterminated string" - exactly how the pf-vdisplay driver install silently failed in the
        # field. Keep every installer-run script pure ASCII (matches install-gamepad-drivers.ps1).
        run: |
          $bad = Get-ChildItem packaging/windows/*.ps1, scripts/windows/*.ps1, scripts/windows/*.cmd -ErrorAction SilentlyContinue |
            Where-Object { [IO.File]::ReadAllText($_.FullName) -match '[^\x00-\x7F]' }
          if ($bad) {
            $bad.FullName | ForEach-Object { Write-Output "::error::non-ASCII in installer-run script: $_" }
            throw "installer-run scripts must be pure ASCII (PS 5.1 mis-parses them on non-UTF-8 locales)"
          }
          Write-Output "installer-run scripts are ASCII-clean"

      - name: Configure + version
        shell: pwsh
        run: |
          # CARGO_TARGET_DIR=C:\t dodges the MAX_PATH wall in the CMake-from-source crates (aws-lc,
          # opus) the host pulls; CARGO_WORKSPACE_DIR mirrors the client workflows. Both via GITHUB_ENV
          # (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean).
          "CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
          "CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
          # audiopus_sys' vendored opus declares cmake_minimum_required < 3.5, which CMake 4.x
          # refuses outright. Green runs today only survive on the cached configure output — a
          # target-dir purge (the runner's disk-cleanup task) would fail the fresh configure, as
          # observed on a clean build on this very runner (2026-07-17). No-op for compliant
          # projects (libvpl-sys pins 3.13+).
          "CMAKE_POLICY_VERSION_MINIMUM=3.5" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
          # FFMPEG_DIR: the same BtbN lgpl-shared x64 tree the Windows CLIENT links against (provisioned
          # by scripts/ci/provision-windows-punktfunk-extras.ps1). The host's AMD/Intel AMF/QSV encode backend
          # (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1
          # then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env.
          if (-not $env:FFMPEG_DIR) {
            "FFMPEG_DIR=C:\Users\Public\ffmpeg" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
          }
          # VBCABLE_DIR: the pinned official VB-CABLE package (provisioned by
          # provision-windows-punktfunk-extras.ps1) -> pack-host-installer.ps1 bundles the
          # streaming virtual microphone. Same daemon-env-or-fallback pattern as FFMPEG_DIR
          # (the daemon env only refreshes on a runner-task restart).
          if (-not $env:VBCABLE_DIR) {
            "VBCABLE_DIR=C:\Users\Public\vbcable" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
          }
          $pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1"   # single source of truth: base is one minor ahead of the latest stable tag
          $v = if ($env:GITHUB_REF -like 'refs/tags/v*') {
            $env:GITHUB_REF_NAME -replace '^v', ''
          } else {
            # Canary: <major>.<minor>.<run> — major.minor track one minor ahead of stable, run climbs monotonically.
            "$($pf.PF_MAJOR).$($pf.PF_MINOR).$($env:GITHUB_RUN_NUMBER)"
          }
          "HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
          "PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
          Write-Output "host version $v"

      - name: Build (release, nvenc + amf-qsv + qsv)
        shell: pwsh
        # All-vendor host: NVENC (NVIDIA, direct SDK) + native QSV (Intel, static VPL dispatcher)
        # + AMF/QSV (AMD + the Intel ffmpeg fallback, libavcodec via FFMPEG_DIR).
        run: cargo build --release -p punktfunk-host --features nvenc,amf-qsv,qsv

      - name: Build (release, status tray)
        shell: pwsh
        # The per-user notification-area companion the installer bundles (punktfunk-tray.exe).
        run: cargo build --release -p punktfunk-tray

      - name: Clippy (host + tray, Windows)
        shell: pwsh
        # First-ever Windows lint coverage for the host (Linux CI never lints the windows-cfg code).
        # --release is REQUIRED, not just faster: a default (debug) clippy compiles the whole dep tree
        # into a SECOND target dir (C:\t\debug), which means a second full build of openh264-sys2's
        # vendored C++ (the software-H.264 fallback in pf-encode) on top of the release copy the Build
        # steps above already produced. That second cc-rs `cl.exe` fan-out tips this runner over into
        # `cabac_decoder.cpp: fatal error C1069 (cannot read compiler command line)` — an environmental
        # disk/temp exhaustion, NOT a source error (the identical file compiles fine in the release
        # build minutes earlier). Linting in release reuses those native build-script artifacts (no
        # openh264 rebuild), and keeps everything in one C:\t\release tree. Same reason
        # pf-vkhdr-layer's clippy below runs --release.
        #
        # pf-encode and pf-capture are linted SEPARATELY with --all-targets so their Windows
        # `#[cfg(test)]` modules are type-checked — pf-encode's AMF C-ABI layout assertions
        # (`variant_layout_matches_c` and friends, which are the only guard on a hand-mirrored
        # vtable ABI), the QSV tests, the PyroWave-Windows smoke test; pf-capture's `StallWatch`
        # tests, the DXGI HDR self-tests and the cursor-conversion tables. The host lint above
        # cannot cover them: `-p punktfunk-host` only builds those crates as dependencies, so their
        # test targets are never compiled anywhere, and that blind spot is what let the Linux twin's
        # tests rot to the wrong arity unnoticed. pf-capture has no cargo features, so it needs no
        # feature juggling and pulls in no extra dep tree.
        # NOTE: for the HOST and pf-encode, clippy (a check, no link step) is deliberately the
        # vehicle — `cargo test` with `nvenc` cannot LINK on MSVC: nvidia-video-codec-sdk
        # link-imports NvEncodeAPICreateInstance / NvEncodeAPIGetMaxSupportedVersion, which resolve
        # only against the driver's import lib. (On Linux the same crate dlopens them, so ci.yml can
        # and does run the tests there.) Running them here would need an `--features amf-qsv,qsv`
        # build without `nvenc`, i.e. a third full dep tree on a runner that already trips C1069 —
        # not worth it while ci.yml executes the same tests.
        #
        # That reasoning does NOT extend to pf-capture: it has no encoder dependency at all
        # (`cargo tree -p pf-capture` lists no nvidia/ffmpeg/libvpl/pyrowave), so its test binary
        # links against nothing this runner lacks, and it reuses the release artifacts the steps
        # above already built. Its Windows `#[test]`s — StallWatch, the f16 conversions, the cursor
        # truth table, the IDD generation masking — are Windows-only code that NO other job can
        # execute, so linting them was leaving real coverage on the table. See the run step below.
        run: |
          cargo clippy --release -p punktfunk-host --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "host clippy" }
          cargo clippy --release -p pf-encode --all-targets --features nvenc,amf-qsv,qsv -- -D warnings; if ($LASTEXITCODE) { throw "pf-encode clippy" }
          cargo clippy --release -p pf-capture --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-capture clippy" }
          cargo clippy --release -p punktfunk-tray -- -D warnings; if ($LASTEXITCODE) { throw "tray clippy" }

      - name: Test (pf-capture, Windows)
        shell: pwsh
        # The only Rust tests that RUN on Windows CI. pf-capture's `#[cfg(target_os = "windows")]`
        # test modules cover code no Linux job compiles, let alone executes: 19 declared, of which
        # 18 execute here — the IDD-push StallWatch state machine and ring-generation masking
        # (idd_push.rs), the cursor shape→wire truth table (idd_push/cursor_poll.rs), and
        # `f32_to_f16` including the rounding-carry / saturation edges the HDR P010 path depends on
        # (dxgi/selftest.rs). All 18 are pure — no Win32, no device, no desktop. The 19th,
        # `hdr_p010_selftest_intel_1080_live`, is `#[ignore]`d because it needs a real Intel
        # adapter; it stays a manual `-- --ignored` run on the validation boxes. Until this step
        # the whole set was type-checked by the clippy line above and nothing more.
        #
        # --release for the same reason as the clippy step: it reuses C:\t\release instead of
        # spawning a second debug dep tree (the C1069 disk-exhaustion trigger). If this step ever
        # starts tripping C1069 anyway, record THAT here rather than quietly dropping the step.
        #
        # The link question this step turns on was settled empirically before it was added: the same
        # command was run on a Windows dev box against a workspace checkout and linked + executed
        # cleanly, building in ~51 s off an existing release target dir.
        run: |
          cargo test --release -p pf-capture; if ($LASTEXITCODE) { throw "pf-capture tests" }

      - name: Build + lint the HDR Vulkan layer (pf-vkhdr-layer)
        shell: pwsh
        # Standalone cdylib (own [workspace]) the installer bundles + registers (it lets Vulkan games
        # like Doom use HDR on the virtual display). Lint here so a regression fails CI instead of
        # silently shipping the host without the layer (pack-host-installer.ps1 builds it non-fatally).
        # Windows-only FFI (user32 + the vk_layer loader glue) → can't be linted on the Linux CI.
        run: |
          Push-Location packaging/windows/pf-vkhdr-layer
          cargo fmt --check; if ($LASTEXITCODE) { throw "pf-vkhdr-layer rustfmt" }
          cargo clippy --release -- -D warnings; if ($LASTEXITCODE) { throw "pf-vkhdr-layer clippy" }
          Pop-Location

      - name: Fetch portable bun runtime (build tool + bundled to run the console)
        shell: pwsh
        run: |
          # ONE pinned bun, used both to BUILD the console and shipped in the installer to RUN it. The
          # .output is self-contained (Nitro noExternals — deps bundled + tree-shaken, no node_modules),
          # so the installer ships just bun + a ~75-file .output instead of node + a node_modules forest.
          $ver = 'bun-v1.3.14'
          $url = "https://github.com/oven-sh/bun/releases/download/$ver/bun-windows-x64.zip"
          New-Item -ItemType Directory -Force -Path C:\t | Out-Null
          $zip = 'C:\t\bun.zip'; $dst = 'C:\t\bundist'
          Invoke-WebRequest -Uri $url -OutFile $zip
          if (Test-Path $dst) { Remove-Item $dst -Recurse -Force }
          Expand-Archive -Path $zip -DestinationPath $dst -Force
          $bun = (Get-ChildItem -Path $dst -Recurse -Filter bun.exe | Select-Object -First 1).FullName
          if (-not $bun) { throw "bun.exe not found in $url" }
          "BUN_EXE=$bun" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
          & $bun --version

      - name: Build + smoke-boot web console (bun)
        shell: pwsh
        env:
          # PAT with read access to the unom org packages — the @unom npm registry needs auth to BUILD.
          REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
        # The bun fetched above builds the Nitro server AND runs it. noExternals (vite.config) makes the
        # output self-contained, so there's no .output/server install — the installer ships bun + the
        # ~75-file .output. The runner is SYSTEM with no ~/.npmrc, so supply the private @unom token in
        # the SYSTEM home .npmrc to BUILD (kept OUT of the shipped bundle — web\.npmrc has only the
        # registry mapping, and nothing copies it into .output).
        run: |
          $bun = $env:BUN_EXE
          if ($env:REGISTRY_TOKEN) {
            $rc = Join-Path $env:USERPROFILE '.npmrc'
            Add-Content -Path $rc -Value '@unom:registry=https://git.unom.io/api/packages/unom/npm/'
            Add-Content -Path $rc -Value "//git.unom.io/api/packages/unom/npm/:_authToken=$env:REGISTRY_TOKEN"
          }
          Push-Location web
          & $bun install --frozen-lockfile; if ($LASTEXITCODE) { throw "bun install failed ($LASTEXITCODE)" }
          & $bun run build; if ($LASTEXITCODE) { throw "web build failed ($LASTEXITCODE)" }
          if (-not (Select-String -Path .output\server\index.mjs -Pattern 'Bun\.serve' -Quiet)) {
            throw "web build is not a bun bundle - need the 'bun' preset + custom entry"
          }
          Pop-Location
          # Gate the installer on a real boot under the BUNDLED bun (the runtime it ships), serving /login.
          $env:PORT = '3009'; $env:HOST = '127.0.0.1'; $env:PUNKTFUNK_UI_PASSWORD = 'ci'
          $server = (Resolve-Path 'web\.output\server\index.mjs').Path
          $p = Start-Process -FilePath $bun -ArgumentList $server -PassThru -WindowStyle Hidden
          Start-Sleep -Seconds 4
          try { $code = (Invoke-WebRequest -Uri 'http://127.0.0.1:3009/login' -UseBasicParsing -TimeoutSec 10).StatusCode } catch { $code = 0 }
          Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
          Write-Output "web console smoke (bun): /login -> $code"
          if ($code -ne 200) { throw "web console failed to boot under bun" }
          "WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8

      - name: Build plugin/script runner bundle (bun)
        shell: pwsh
        # `bun build --target=bun` bundles the SDK's runner CLI to ONE self-contained JS (effect + the
        # SDK inlined; the dynamic plugin import stays a runtime import). pack-host-installer.ps1 ships
        # it (+ the shared bun) and registers its scheduled task DISABLED (opt-in). The SDK's deps are
        # public npm (effect), so no @unom token is needed here.
        run: |
          $bun = $env:BUN_EXE
          Push-Location sdk
          & $bun install --frozen-lockfile --ignore-scripts; if ($LASTEXITCODE) { throw "sdk bun install failed ($LASTEXITCODE)" }
          New-Item -ItemType Directory -Force -Path C:\t\scripting | Out-Null
          & $bun build src/runner-cli.ts --target=bun --outfile=C:\t\scripting\runner-cli.js; if ($LASTEXITCODE) { throw "runner bundle build failed ($LASTEXITCODE)" }
          Pop-Location
          if (-not (Select-String -Path C:\t\scripting\runner-cli.js -Pattern 'attempt=' -Quiet)) {
            throw "runner bundle missing the dynamic plugin import - wrong build"
          }
          "SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8

      - name: Pack + sign installer
        shell: pwsh
        env:
          MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
          MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
        run: |
          & packaging/windows/pack-host-installer.ps1 `
            -Version $env:HOST_VERSION -TargetDir C:\t\release -OutDir C:\t\out

      - name: Publish to Gitea generic registry
        shell: pwsh
        env:
          REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
        run: |
          # Check curl's exit code ourselves — a best-effort DELETE (404 on first run) must not abort.
          $PSNativeCommandUseErrorActionPreference = $false
          function Publish-File($f, $url) {
            # The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
            # re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel
            # alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the
            # status code instead of aborting on it.
            $code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url")
            if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" }
            if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return }
            if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" }
            Write-Output "published ($code): $url"
          }
          $files = @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
          if (-not $files) { throw "pack produced no artifacts to publish" }
          $base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)"
          foreach ($f in $files) { Publish-File $f "$base/$($env:HOST_VERSION)/$(Split-Path $f -Leaf)" }
          # Refresh the channel alias (delete-then-reupload, like flatpak.yml/decky.yml) for a
          # predictable download URL: stable release -> `latest/`, canary main build -> `canary/`.
          $alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' }
          $aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' }
          foreach ($f in $files) {
            $an = $aliasNames[$f]; if (-not $an) { continue }
            curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null
            Publish-File $f "$base/$alias/$an"
          }

      # On a real release, also attach the signed installer (+ its .cer) to the unified Gitea Release.
      - name: Attach host installer to the Gitea release (stable tags only)
        if: startsWith(gitea.ref, 'refs/tags/v')
        shell: pwsh
        env:
          GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
        run: |
          . scripts/ci/gitea-release.ps1
          $rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
          foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
            if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
          }

      # winget manifests for the release just attached above. Runs AFTER the attach step so the
      # InstallerUrl the manifest pins is already live — winget validates the URL + hash, and a
      # manifest published ahead of its artifact is a hard 404 for every client that picks it up.
      # Stable tags only: winget pins one immutable artifact per version, so the rolling `canary/`
      # alias has nothing it could point at.
      - name: Emit + attach winget manifests (stable tags only)
        if: startsWith(gitea.ref, 'refs/tags/v')
        shell: pwsh
        env:
          GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
        run: |
          & scripts/ci/winget-manifest.ps1 `
            -Version $env:HOST_VERSION -InstallerPath $env:HOST_SETUP_PATH -OutDir C:\t\out\winget
          . scripts/ci/gitea-release.ps1
          $rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
          foreach ($f in (Get-ChildItem C:\t\out\winget -Filter *.yaml)) {
            Upsert-GiteaAsset -ReleaseId $rid -File $f.FullName
          }

  # Republish the winget REST source on unom-1 once the release above carries its manifests.
  #
  # A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
  # not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
  # reads the manifests from the release, so it must not run before they are attached.
  winget-source:
    needs: package
    if: startsWith(gitea.ref, 'refs/tags/v')
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4

      # build-data re-derives the WHOLE catalogue from the releases rather than appending this one,
      # so the result cannot drift and re-running any tag reproduces it byte for byte.
      - name: Build + test the source catalogue
        working-directory: packaging/winget/server
        run: |
          set -euo pipefail
          npm install --no-audit --no-fund
          node build-data.mjs --out data/data.json
          # A wrong response SHAPE does not fail loudly — winget just reports "no package found".
          # Gate on the suite before anything reaches the box.
          node test.mjs

      # Content only. server.mjs/handler.mjs/compose land via deploy-services.yml, matching how the
      # flatpak repo's content and config deploy on separate paths.
      - name: Ship the catalogue to unom-1
        uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          port: ${{ secrets.DEPLOY_PORT }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          source: "packaging/winget/server/data/data.json"
          target: "~/unom-winget/data"
          strip_components: 4
          overwrite: true

      # No restart: server.mjs reloads on mtime change. This only proves the new catalogue is the
      # one actually being served, and fails the release if it is not.
      - name: Verify the served catalogue
        uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          port: ${{ secrets.DEPLOY_PORT }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            set -euo pipefail
            curl -fsS http://127.0.0.1:3240/healthz
            echo
            curl -fsS -X POST http://127.0.0.1:3240/manifestSearch \
              -H 'content-type: application/json' -d '{"FetchAllManifests":true}' \
              | grep -q "${GITHUB_REF_NAME#v}" \
              || { echo "served catalogue does not contain ${GITHUB_REF_NAME#v}"; exit 1; }
            echo "winget source serving ${GITHUB_REF_NAME#v}"
        env:
          GITHUB_REF_NAME: ${{ gitea.ref_name }}
        # `env:` populates the RUNNER's environment; this action runs `script` on the REMOTE
        # host, which inherits nothing from it. `envs:` is the action's own allow-list of names
        # to forward into the remote shell — without it `set -u` aborted at the first expansion
        # ("GITHUB_REF_NAME: unbound variable") and the step failed on every tag, after the
        # catalogue had already shipped correctly.
        envs: GITHUB_REF_NAME
