From d76df74ee44d5fdc26a2999b7c04ca2a54c6231a Mon Sep 17 00:00:00 2001 From: rowkav09 Date: Sat, 26 Sep 2026 10:58:11 +0100 Subject: [PATCH 1/5] test: exercise downloaded development installer and portable bundle on Windows --- .github/workflows/dev-artifact-validation.yml | 23 ++++++ scripts/verify-dev-artifacts.ps1 | 75 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 .github/workflows/dev-artifact-validation.yml create mode 100644 scripts/verify-dev-artifacts.ps1 diff --git a/.github/workflows/dev-artifact-validation.yml b/.github/workflows/dev-artifact-validation.yml new file mode 100644 index 00000000..58c46029 --- /dev/null +++ b/.github/workflows/dev-artifact-validation.yml @@ -0,0 +1,23 @@ +name: Dev artifact real-install validation +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/dev-artifact-validation.yml" + - "scripts/verify-dev-artifacts.ps1" +permissions: + contents: read + attestations: read +jobs: + real-install: + runs-on: windows-2025 + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Download and exercise moving dev installer and portable ZIP + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: ./scripts/verify-dev-artifacts.ps1 diff --git a/scripts/verify-dev-artifacts.ps1 b/scripts/verify-dev-artifacts.ps1 new file mode 100644 index 00000000..c21d9203 --- /dev/null +++ b/scripts/verify-dev-artifacts.ps1 @@ -0,0 +1,75 @@ +# Exercise the assets users download, not a fresh rebuild of the repository. +$ErrorActionPreference = 'Stop' +$repo = 'rowkavdev/nowplaying' +$expectedSha = '4406f562f16a2b49d96d5a046dfd3152450c718b' +$root = Join-Path $env:RUNNER_TEMP 'nowplaying-dev-artifact-check' +New-Item -ItemType Directory -Path $root -Force | Out-Null +$release = gh release view dev --repo $repo --json assets,tagName | ConvertFrom-Json +if ($LASTEXITCODE -ne 0 -or $release.tagName -ne 'dev') { throw 'Dev release lookup failed' } +$tag = (gh api "repos/$repo/git/ref/tags/dev" --jq '.object.sha').Trim() +if ($LASTEXITCODE -ne 0 -or $tag -ne $expectedSha) { throw "Moving dev tag changed: $tag" } +$files = @('nowplaying-dev-windows-x64-setup.exe', 'nowplaying-dev-windows-x64.zip', 'SHA256SUMS') +foreach ($name in $files) { + $asset = @($release.assets | Where-Object name -eq $name) + if ($asset.Count -ne 1 -or $asset[0].digest -notmatch '^sha256:[a-f0-9]{64}$') { throw "Missing asset/digest: $name" } + gh release download dev --repo $repo --dir $root --pattern $name + if ($LASTEXITCODE -ne 0) { throw "Download failed: $name" } + $actual = (Get-FileHash (Join-Path $root $name) -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $asset[0].digest.Substring(7)) { throw "GitHub digest mismatch: $name" } +} +$manifest = [IO.File]::ReadAllText((Join-Path $root 'SHA256SUMS')) +if ($manifest.Contains("`r") -or $manifest[0] -eq [char]0xfeff) { throw 'SHA256SUMS has CR or BOM' } +foreach ($name in $files[0..1]) { + $digest = (Get-FileHash (Join-Path $root $name) -Algorithm SHA256).Hash.ToLowerInvariant() + if (-not $manifest.Contains("$digest $name`n")) { throw "SHA256SUMS mismatch: $name" } + gh attestation verify (Join-Path $root $name) --repo $repo --signer-workflow "$repo/.github/workflows/beta.yml" + if ($LASTEXITCODE -ne 0) { throw "Provenance verification failed: $name" } +} +function Test-Bundle($dir, $label) { + $exe = Join-Path $dir 'nowplaying.exe' + if (-not (Test-Path $exe)) { throw "$label has no launcher" } + Push-Location $dir + try { + $version = (& $exe --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $version -ne '0.2.0') { throw "$label version failed: $version" } + $data = Join-Path $root "data-$label" + New-Item -ItemType Directory -Path $data -Force | Out-Null + $env:LOCALAPPDATA = $data + $env:NOWPLAYING_PORT = if ($label -eq 'installer') { '47842' } else { '47843' } + $stdout = Join-Path $root "$label-out.log" + $stderr = Join-Path $root "$label-err.log" + $app = Start-Process -FilePath $exe -ArgumentList 'start','--no-tray','--no-setup' -WorkingDirectory $dir -PassThru -RedirectStandardOutput $stdout -RedirectStandardError $stderr + try { + $base = "http://127.0.0.1:$env:NOWPLAYING_PORT" + $ready = $false + for ($i = 0; $i -lt 100; $i++) { + if ($app.HasExited) { throw "$label exited $($app.ExitCode): $(Get-Content $stderr -Raw)" } + try { if ((Invoke-WebRequest "$base/healthz" -UseBasicParsing -TimeoutSec 2).Content -eq "ok`n") { $ready = $true; break } } catch {} + Start-Sleep -Milliseconds 300 + } + if (-not $ready) { throw "$label did not serve healthz: $(Get-Content $stderr -Raw)" } + $page = Invoke-WebRequest "$base/settings" -UseBasicParsing + if ($page.StatusCode -ne 200 -or $page.Content -notmatch 'Set up NowPlaying') { throw "$label first-run WebUI missing" } + $servers = Invoke-RestMethod "$base/api/settings/servers" + if (-not $servers.firstRun) { throw "$label did not enter first-run" } + $discovery = Invoke-WebRequest "$base/api/setup/discover" -UseBasicParsing + if ($discovery.StatusCode -ne 200) { throw "$label discovery API failed" } + Write-Host "${label}: version, launch, healthz, first-run WebUI and discovery API passed" + } finally { + taskkill /PID $app.Id /T /F | Out-Null + } + } finally { Pop-Location } +} +$zipDir = Join-Path $root 'portable' +Expand-Archive (Join-Path $root $files[1]) $zipDir -Force +Test-Bundle $zipDir 'portable' +$installDir = Join-Path $root 'installed' +$installer = Join-Path $root $files[0] +$setup = Start-Process -FilePath $installer -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/NOICONS',"/DIR=$installDir" -PassThru -Wait +if ($setup.ExitCode -ne 0) { throw "Installer exited $($setup.ExitCode)" } +try { Test-Bundle $installDir 'installer' } +finally { + $uninstall = Join-Path $installDir 'unins000.exe' + if (Test-Path $uninstall) { $un = Start-Process -FilePath $uninstall -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -PassThru -Wait; if ($un.ExitCode -ne 0) { throw "Uninstall failed: $($un.ExitCode)" } } +} +Write-Host 'Both downloaded dev artifacts exercised on Windows.' From 052096aeaca33a1494c3e6491ef2c19945b402c5 Mon Sep 17 00:00:00 2001 From: rowkav09 Date: Sat, 26 Sep 2026 10:59:34 +0100 Subject: [PATCH 2/5] test: normalize health body and expose launch diagnostics --- scripts/verify-dev-artifacts.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/verify-dev-artifacts.ps1 b/scripts/verify-dev-artifacts.ps1 index c21d9203..717a0f81 100644 --- a/scripts/verify-dev-artifacts.ps1 +++ b/scripts/verify-dev-artifacts.ps1 @@ -28,6 +28,7 @@ foreach ($name in $files[0..1]) { function Test-Bundle($dir, $label) { $exe = Join-Path $dir 'nowplaying.exe' if (-not (Test-Path $exe)) { throw "$label has no launcher" } + Write-Host "Testing $label bundle at $dir" Push-Location $dir try { $version = (& $exe --version | Out-String).Trim() @@ -44,10 +45,10 @@ function Test-Bundle($dir, $label) { $ready = $false for ($i = 0; $i -lt 100; $i++) { if ($app.HasExited) { throw "$label exited $($app.ExitCode): $(Get-Content $stderr -Raw)" } - try { if ((Invoke-WebRequest "$base/healthz" -UseBasicParsing -TimeoutSec 2).Content -eq "ok`n") { $ready = $true; break } } catch {} + try { if ((Invoke-WebRequest "$base/healthz" -UseBasicParsing -TimeoutSec 2).Content.Trim() -eq "ok") { $ready = $true; break } } catch {} Start-Sleep -Milliseconds 300 } - if (-not $ready) { throw "$label did not serve healthz: $(Get-Content $stderr -Raw)" } + if (-not $ready) { throw "$label did not serve healthz: stdout=$(Get-Content $stdout -Raw); stderr=$(Get-Content $stderr -Raw); process=$($app.HasExited)" } $page = Invoke-WebRequest "$base/settings" -UseBasicParsing if ($page.StatusCode -ne 200 -or $page.Content -notmatch 'Set up NowPlaying') { throw "$label first-run WebUI missing" } $servers = Invoke-RestMethod "$base/api/settings/servers" From fa95ced2f01e0259128fee2a7f41871f9554758c Mon Sep 17 00:00:00 2001 From: rowkav09 Date: Sat, 26 Sep 2026 11:00:59 +0100 Subject: [PATCH 3/5] test: probe first-run Settings before server configuration --- scripts/verify-dev-artifacts.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/verify-dev-artifacts.ps1 b/scripts/verify-dev-artifacts.ps1 index 717a0f81..2286648a 100644 --- a/scripts/verify-dev-artifacts.ps1 +++ b/scripts/verify-dev-artifacts.ps1 @@ -45,17 +45,17 @@ function Test-Bundle($dir, $label) { $ready = $false for ($i = 0; $i -lt 100; $i++) { if ($app.HasExited) { throw "$label exited $($app.ExitCode): $(Get-Content $stderr -Raw)" } - try { if ((Invoke-WebRequest "$base/healthz" -UseBasicParsing -TimeoutSec 2).Content.Trim() -eq "ok") { $ready = $true; break } } catch {} + try { if ((Invoke-WebRequest "$base/settings" -UseBasicParsing -TimeoutSec 2).StatusCode -eq 200) { $ready = $true; break } } catch {} Start-Sleep -Milliseconds 300 } - if (-not $ready) { throw "$label did not serve healthz: stdout=$(Get-Content $stdout -Raw); stderr=$(Get-Content $stderr -Raw); process=$($app.HasExited)" } + if (-not $ready) { throw "$label did not serve first-run Settings: stdout=$(Get-Content $stdout -Raw); stderr=$(Get-Content $stderr -Raw); process=$($app.HasExited)" } $page = Invoke-WebRequest "$base/settings" -UseBasicParsing if ($page.StatusCode -ne 200 -or $page.Content -notmatch 'Set up NowPlaying') { throw "$label first-run WebUI missing" } $servers = Invoke-RestMethod "$base/api/settings/servers" if (-not $servers.firstRun) { throw "$label did not enter first-run" } $discovery = Invoke-WebRequest "$base/api/setup/discover" -UseBasicParsing if ($discovery.StatusCode -ne 200) { throw "$label discovery API failed" } - Write-Host "${label}: version, launch, healthz, first-run WebUI and discovery API passed" + Write-Host "${label}: version, launch, first-run WebUI and discovery API passed" } finally { taskkill /PID $app.Id /T /F | Out-Null } From 98892193265e684dee34e3616ef49926c2b34509 Mon Sep 17 00:00:00 2001 From: rowkav09 Date: Sat, 26 Sep 2026 11:01:54 +0100 Subject: [PATCH 4/5] test: exercise current Settings discovery endpoint with session --- scripts/verify-dev-artifacts.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/verify-dev-artifacts.ps1 b/scripts/verify-dev-artifacts.ps1 index 2286648a..215e3414 100644 --- a/scripts/verify-dev-artifacts.ps1 +++ b/scripts/verify-dev-artifacts.ps1 @@ -53,8 +53,10 @@ function Test-Bundle($dir, $label) { if ($page.StatusCode -ne 200 -or $page.Content -notmatch 'Set up NowPlaying') { throw "$label first-run WebUI missing" } $servers = Invoke-RestMethod "$base/api/settings/servers" if (-not $servers.firstRun) { throw "$label did not enter first-run" } - $discovery = Invoke-WebRequest "$base/api/setup/discover" -UseBasicParsing - if ($discovery.StatusCode -ne 200) { throw "$label discovery API failed" } + $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession + $settingsPage = Invoke-WebRequest "$base/settings" -UseBasicParsing -WebSession $session + $discovery = Invoke-RestMethod "$base/api/settings/servers/discover" -Method Post -ContentType 'application/json' -Body '{"subnet":""}' -WebSession $session + if ($null -eq $discovery.servers) { throw "$label discovery API failed" } Write-Host "${label}: version, launch, first-run WebUI and discovery API passed" } finally { taskkill /PID $app.Id /T /F | Out-Null From 1509ac36c73f7066d71d55497708afb23a0d55d2 Mon Sep 17 00:00:00 2001 From: rowkav09 Date: Sat, 26 Sep 2026 11:03:50 +0100 Subject: [PATCH 5/5] test: follow moving dev tag and verify each bundle source SHA --- scripts/verify-dev-artifacts.ps1 | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/verify-dev-artifacts.ps1 b/scripts/verify-dev-artifacts.ps1 index 215e3414..10a4e4ce 100644 --- a/scripts/verify-dev-artifacts.ps1 +++ b/scripts/verify-dev-artifacts.ps1 @@ -1,13 +1,12 @@ # Exercise the assets users download, not a fresh rebuild of the repository. $ErrorActionPreference = 'Stop' $repo = 'rowkavdev/nowplaying' -$expectedSha = '4406f562f16a2b49d96d5a046dfd3152450c718b' $root = Join-Path $env:RUNNER_TEMP 'nowplaying-dev-artifact-check' New-Item -ItemType Directory -Path $root -Force | Out-Null $release = gh release view dev --repo $repo --json assets,tagName | ConvertFrom-Json if ($LASTEXITCODE -ne 0 -or $release.tagName -ne 'dev') { throw 'Dev release lookup failed' } $tag = (gh api "repos/$repo/git/ref/tags/dev" --jq '.object.sha').Trim() -if ($LASTEXITCODE -ne 0 -or $tag -ne $expectedSha) { throw "Moving dev tag changed: $tag" } +if ($LASTEXITCODE -ne 0 -or $tag -notmatch '^[a-f0-9]{40}$') { throw "Dev tag lookup failed: $tag" } $files = @('nowplaying-dev-windows-x64-setup.exe', 'nowplaying-dev-windows-x64.zip', 'SHA256SUMS') foreach ($name in $files) { $asset = @($release.assets | Where-Object name -eq $name) @@ -28,6 +27,8 @@ foreach ($name in $files[0..1]) { function Test-Bundle($dir, $label) { $exe = Join-Path $dir 'nowplaying.exe' if (-not (Test-Path $exe)) { throw "$label has no launcher" } + $info = Get-Content (Join-Path $dir 'app/build-info.json') -Raw | ConvertFrom-Json + if ($info.commitSha -ne $tag) { throw "$label was built from $($info.commitSha), not dev tag $tag" } Write-Host "Testing $label bundle at $dir" Push-Location $dir try { @@ -75,4 +76,6 @@ finally { $uninstall = Join-Path $installDir 'unins000.exe' if (Test-Path $uninstall) { $un = Start-Process -FilePath $uninstall -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -PassThru -Wait; if ($un.ExitCode -ne 0) { throw "Uninstall failed: $($un.ExitCode)" } } } -Write-Host 'Both downloaded dev artifacts exercised on Windows.' +$tagAfter = (gh api "repos/$repo/git/ref/tags/dev" --jq '.object.sha').Trim() +if ($LASTEXITCODE -ne 0 -or $tagAfter -ne $tag) { throw "Dev tag moved during test ($tag -> $tagAfter); rerun for the new build" } +Write-Host "Both downloaded dev artifacts exercised on Windows at $tag."