Every payload the job bundles is now asserted before packing: the console, the bun runtime, the plugin runner, the FFmpeg DLLs and VB-CABLE. Each is optional to pack-host-installer.ps1 — right for a local debug pack, and the reason 0.22.1 and 0.22.2 shipped with no web console: one unset variable omitted it behind a single line of log and the build stayed green. CI knows it bundles all five, so a missing input belongs here as a failure rather than downstream as a quietly smaller installer. FFmpeg is the one that would hurt most and was silent too: an amf-qsv host link-imports avcodec, so an installer missing those DLLs ships a host that cannot start at all, and FFMPEG_DIR is a fallback to a provisioned path that nothing verified. The shape is borrowed from the packer's own VB-CABLE check, which already throws on a supplied-but-empty dir "instead of silently shipping an installer without the virtual mic - exactly the field regression this bundling fixes". Same lesson, applied to the rest. Both paths were exercised on the Windows runner: all five unset fails with exit 1 naming each one, all five present passes with exit 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
597 lines
37 KiB
YAML
597 lines
37 KiB
YAML
# 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). That fallback is for canary/CI ONLY — on a v* tag
|
||
# the pack script FAILS CLOSED rather than ship a release signed by a per-build throwaway cert.
|
||
# 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
|
||
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
|
||
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
|
||
# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the
|
||
# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933).
|
||
concurrency:
|
||
group: ${{ github.workflow }}-${{ github.ref }}
|
||
cancel-in-progress: true
|
||
|
||
|
||
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
|
||
RUSTC_WRAPPER: sccache
|
||
SCCACHE_BUCKET: unom-ci-sccache
|
||
SCCACHE_ENDPOINT: https://storage.unom.io
|
||
SCCACHE_REGION: home-central
|
||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
|
||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
|
||
# sccache and incremental compilation are mutually exclusive; CI wants the shared
|
||
# cache, dev boxes keep incremental.
|
||
CARGO_INCREMENTAL: "0"
|
||
|
||
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; 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
|
||
# 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, pf-capture and pf-vdisplay 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 pf-vdisplay --all-targets -- -D warnings; if ($LASTEXITCODE) { throw "pf-vdisplay 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: Test (pf-vdisplay, Windows)
|
||
shell: pwsh
|
||
# pf-vdisplay's Windows half is ~3,400 lines (manager.rs, pf_vdisplay.rs, ddc.rs, the three
|
||
# manager/ submodules) that NO other job compiles — the host lint above builds the crate as
|
||
# a dependency, so its test targets were reaching no compiler anywhere. Worse, the only two
|
||
# Windows `#[test]`s were `if env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() { return; }`
|
||
# early-returns, so an unrun hardware test reported `ok`. They are `#[ignore]`d now and this
|
||
# step reports them as `ignored`, which is the truth.
|
||
#
|
||
# The link objection recorded for the host and pf-encode above does NOT apply here, for the
|
||
# same reason it does not apply to pf-capture: `pf-encode` is `default = []`, and nothing in
|
||
# `-p pf-vdisplay`'s graph turns on `nvenc`/`amf-qsv`/`qsv`, so no nvidia/ffmpeg/libvpl
|
||
# import libs are ever asked for. Settled empirically before this step was added, to the
|
||
# same standard as pf-capture's: the exact two commands were run on this runner against a
|
||
# checkout at C:\temp\pf-vd-check — clippy clean, then 46 passed / 2 ignored in 0.19 s.
|
||
#
|
||
# --release to reuse C:\t\release rather than spawning a debug tree (the C1069 trigger).
|
||
# Note this DOES build a second, featureless pf-encode; it is small precisely because none
|
||
# of the encoder features are on.
|
||
run: |
|
||
cargo test --release -p pf-vdisplay; if ($LASTEXITCODE) { throw "pf-vdisplay 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
|
||
|
||
# The console output is fully self-contained (Nitro noExternals) and most pushes
|
||
# don't touch web/ or sdk/ — restore it from the central cache and skip the ~2.5 min
|
||
# bun build+smoke entirely on a hit. First workflow on this runner to use the
|
||
# actions cache at all (the runner's config.yaml needed cache.external_server —
|
||
# see unom/infra runners/ci-core/README.md).
|
||
- name: Cache web console output
|
||
id: webconsole
|
||
uses: actions/cache@v4
|
||
with:
|
||
path: web/.output
|
||
key: web-console-win-${{ hashFiles('web/**', 'sdk/**') }}
|
||
|
||
- 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)
|
||
if: steps.webconsole.outputs.cache-hit != 'true'
|
||
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
|
||
# `--ignore-scripts` like every other web install in CI (ci.yml, web-screenshots.yml,
|
||
# sdk/plugin-kit-publish, and the SDK install further down this same file). This step was
|
||
# the one site that ran lifecycle scripts, and web's `postinstall` is `bun2nix -o bun.nix`
|
||
# — a NIX codegen step that shells out to `bun` on PATH. CI runs a fetched PORTABLE bun by
|
||
# absolute path (`$env:BUN_EXE`), so PATH has none, and bun2nix aborted the install:
|
||
# error: bun is not installed in %PATH% ... postinstall script exited with 255
|
||
# Nothing here needs those scripts — `build` re-runs its own `prebuild` codegen — and
|
||
# bun.nix is a Nix artifact this job neither consumes nor commits.
|
||
& $bun install --frozen-lockfile --ignore-scripts; 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 has to be exported whether or not the step above ran. It used to be that step's
|
||
# last line, so a CACHE HIT skipped it and left the variable unset — and pack-host-installer.ps1
|
||
# treats an unset WEB_OUTPUT_DIR as "don't bundle the console", silently ("installer built
|
||
# WITHOUT the web console"). That shipped in 0.22.1 and 0.22.2: no {app}\web, so no web-run.cmd,
|
||
# so `web setup` bails, so no PunktfunkWeb task and no console at all. It also removed the only
|
||
# thing that stopped bun before the copy (StopBunRuntimes was #ifdef WithWeb), while bun.exe kept
|
||
# shipping under WithScripting — which is the "DeleteFile failed; code 5" modal on bun.exe.
|
||
# The throw is the point: never silently ship a console-less installer again.
|
||
- name: Export the console output dir (cache hit or fresh build)
|
||
shell: pwsh
|
||
run: |
|
||
if (-not (Test-Path 'web\.output\server\index.mjs')) {
|
||
throw "web\.output is missing - neither the cache restore nor the build produced it, and the installer must not ship without the console"
|
||
}
|
||
"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
|
||
|
||
# NOT cached, and it must stay that way: the UMDF drivers build IN-TREE inside the pack
|
||
# step (a relocated CARGO_TARGET_DIR breaks wdk-build's manifest walk), and act rotates
|
||
# the job workspace path (~/.cache/act/<hash>/hostexecutor) between runs. A cargo target
|
||
# dir restored under a DIFFERENT absolute path brings state that points at the old one:
|
||
# measured 2026-07-30, `pf-umdf-util` died with 14 × "unable to create file lock (os
|
||
# error 3)" and took the whole job with it. ~1 min of rebuild is the correct price; the
|
||
# same rotation is why the other Windows jobs use a fixed C:\t instead of a cached
|
||
# workspace-relative target.
|
||
# Every payload this job is SUPPOSED to bundle, asserted before packing. The packer treats each
|
||
# one as optional — correct for a local debug pack, and the reason 0.22.1/0.22.2 shipped with no
|
||
# web console: an unset WEB_OUTPUT_DIR omitted it behind a single Write-Host. CI knows it bundles
|
||
# all of these, so here a missing input is a build failure rather than a quietly smaller
|
||
# installer. (pack-host-installer.ps1 already does this for VB-CABLE, for the same reason.)
|
||
- name: Verify every installer payload is present
|
||
shell: pwsh
|
||
run: |
|
||
$need = @(
|
||
@{ n = 'web console (WEB_OUTPUT_DIR)'; p = $env:WEB_OUTPUT_DIR; f = 'server\index.mjs' }
|
||
@{ n = 'bun runtime (BUN_EXE)'; p = $env:BUN_EXE; f = '' }
|
||
@{ n = 'plugin runner (SCRIPTING_BUNDLE)';p = $env:SCRIPTING_BUNDLE; f = '' }
|
||
@{ n = 'FFmpeg DLLs (FFMPEG_DIR\bin)'; p = $env:FFMPEG_DIR; f = 'bin' }
|
||
@{ n = 'VB-CABLE (VBCABLE_DIR)'; p = $env:VBCABLE_DIR; f = 'VBCABLE_Setup_x64.exe' }
|
||
)
|
||
$missing = @()
|
||
foreach ($x in $need) {
|
||
if (-not $x.p) { $missing += "$($x.n): env var not set"; continue }
|
||
$full = if ($x.f) { Join-Path $x.p $x.f } else { $x.p }
|
||
if (-not (Test-Path $full)) { $missing += "$($x.n): missing $full" }
|
||
else { Write-Output "payload OK - $($x.n) -> $full" }
|
||
}
|
||
if ($missing.Count) {
|
||
$missing | ForEach-Object { Write-Output "MISSING PAYLOAD - $_" }
|
||
throw "$($missing.Count) installer payload(s) missing - refusing to ship an incomplete installer"
|
||
}
|
||
|
||
- name: Pack + sign installer
|
||
shell: pwsh
|
||
env:
|
||
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
|
||
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
|
||
# The DRIVER cert is separate from the host/MSIX one and reaches the two driver build
|
||
# scripts through the environment (pack-host-installer.ps1 invokes them, they read
|
||
# $env:DRIVER_CERT_PFX_B64 themselves). Without it they sign with a per-build throwaway,
|
||
# which the installer then trusts as a machine root — see packaging/windows/README.md.
|
||
DRIVER_CERT_PFX_B64: ${{ secrets.DRIVER_CERT_PFX_B64 }}
|
||
DRIVER_CERT_PASSWORD: ${{ secrets.DRIVER_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.
|
||
# Publish the SIGNED canary update manifest after the canary installer lands (planning:
|
||
# host-update-from-web-console.md §3.3 — canary rides this workflow because the installer is
|
||
# the only artifact the manifest references by URL; other canary channels may trail by minutes,
|
||
# which the per-PM apply path tolerates). A Linux job: the signer is bash+openssl. Skips (with
|
||
# a warning) when UPDATE_MANIFEST_KEY is absent — a canary build must not fail over it.
|
||
canary-manifest:
|
||
needs: package
|
||
if: gitea.ref == 'refs/heads/main'
|
||
runs-on: ubuntu-24.04
|
||
timeout-minutes: 10
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: Publish the canary update manifest
|
||
env:
|
||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
|
||
run: |
|
||
set -euo pipefail
|
||
# Same derivation the package job used: canary = <next-minor base>'s major.minor + run#.
|
||
eval "$(bash scripts/ci/pf-version.sh)"
|
||
VER="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"
|
||
URL="https://${REGISTRY}/api/packages/${OWNER}/generic/${PKG}/${VER}/punktfunk-host-setup-${VER}.exe"
|
||
curl -fsSL "$URL" -o /tmp/installer.exe
|
||
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
|
||
CHANNEL=canary VERSION="$VER" CI_RUN="${GITHUB_RUN_NUMBER}" \
|
||
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
|
||
NOTES_URL="https://git.unom.io/unom/punktfunk/releases" \
|
||
bash scripts/ci/publish-update-manifest.sh
|
||
|
||
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:` below populates the RUNNER's environment; this action runs `script` on the
|
||
# REMOTE host, which inherits nothing from it. `envs:` is the action's OWN input —
|
||
# it must live under `with:` (matching docker.yml/deploy-services.yml's REGISTRY_TOKEN
|
||
# forwarding) — naming the variables to forward into the remote shell. A prior fix put
|
||
# it as a step-level sibling of `with:`/`env:` instead: that key is not part of the
|
||
# step schema, so appleboy/ssh-action never received it as an input and the step kept
|
||
# failing ("GITHUB_REF_NAME: unbound variable") on every tag after 24d2f97e too.
|
||
envs: GITHUB_REF_NAME
|
||
env:
|
||
GITHUB_REF_NAME: ${{ gitea.ref_name }}
|