From 4810eb3e243df9fb49cb0bc53e9473134dc55aca Mon Sep 17 00:00:00 2001 From: Hampus Date: Fri, 9 Oct 2026 05:40:01 +0200 Subject: [PATCH] feat(ci): run the release check on built Windows shells (#3346) --- .github/workflows/build-desktop.yaml | 58 +++++++- .../desktop-windows-release-check.yaml | 127 ++++++++++++++++++ fluxer_desktop/scripts/release-check.mjs | 30 ++++- tools/ci/src/desktop.rs | 32 +++++ 4 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/desktop-windows-release-check.yaml diff --git a/.github/workflows/build-desktop.yaml b/.github/workflows/build-desktop.yaml index 5ca7ab1d9..bece4e189 100644 --- a/.github/workflows/build-desktop.yaml +++ b/.github/workflows/build-desktop.yaml @@ -551,6 +551,16 @@ jobs: cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop --step verify_windows_unpacked_signatures + - name: Verify the packaged shell starts and boots its bundled renderer (Windows) + if: matrix.platform == 'windows' && matrix.arch == 'x64' + working-directory: ${{ env.WORKDIR }}/fluxer_desktop + env: + BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }} + BUILD_VERSION: ${{ env.VERSION }} + run: >- + cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop + --step verify_bundled_renderer_windows + - name: Create portable ZIP (Windows) if: matrix.platform == 'windows' working-directory: ${{ env.WORKDIR }}/fluxer_desktop @@ -661,13 +671,59 @@ jobs: retention-days: 1 compression-level: 0 + verify_windows_arm64: + name: Verify windows (arm64) + if: ${{ !cancelled() && needs.build.result == 'success' }} + needs: + - meta + - build + runs-on: windows-11-arm + timeout-minutes: 30 + permissions: + actions: read + contents: read + env: + CHANNEL: ${{ needs.meta.outputs.build_channel }} + VERSION: ${{ needs.meta.outputs.version }} + steps: + - name: Checkout source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ needs.meta.outputs.source_sha }} + sparse-checkout: fluxer_desktop/scripts + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 24 + + - name: Download windows arm64 build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + path: artifacts + pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-windows-arm64* + + - name: Verify the packaged shell starts and boots its bundled renderer + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $zip = Get-ChildItem -Path artifacts -Recurse -Filter "*-$env:VERSION-portable-win-arm64.zip" | Select-Object -First 1 + if ($null -eq $zip) { Write-Host 'This build has no windows arm64 portable zip'; exit 0 } + $app = Join-Path $env:RUNNER_TEMP 'app' + Expand-Archive -Path $zip.FullName -DestinationPath $app + $exe = Get-ChildItem -Path $app -Recurse -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1 + if ($null -eq $exe) { throw "$($zip.Name) holds no Fluxer executable" } + node fluxer_desktop/scripts/release-check.mjs --app $exe.DirectoryName --channel $env:CHANNEL --expect-renderer $env:VERSION --expect-source bundled --package-origin http://127.0.0.1:9 --app-arg=--disable-gpu --workdir (Join-Path $env:RUNNER_TEMP 'runs') --timeout-seconds 240 + if ($LASTEXITCODE -ne 0) { throw "The windows arm64 build fails its release check" } + upload: name: Assemble desktop release assets - if: ${{ !cancelled() && needs.build.result == 'success' }} + if: ${{ !cancelled() && needs.build.result == 'success' && needs.verify_windows_arm64.result == 'success' }} needs: - meta - shared_assets - build + - verify_windows_arm64 runs-on: ubuntu-24.04-arm environment: desktop-releases timeout-minutes: 180 diff --git a/.github/workflows/desktop-windows-release-check.yaml b/.github/workflows/desktop-windows-release-check.yaml new file mode 100644 index 000000000..ac688f26f --- /dev/null +++ b/.github/workflows/desktop-windows-release-check.yaml @@ -0,0 +1,127 @@ +name: desktop windows release check + +on: + workflow_dispatch: + inputs: + channel: + description: Release channel + type: choice + options: + - canary + - stable + default: canary + version: + description: Published version to check + required: true + type: string + expect_failure: + description: Pass only when the published build fails to start + type: boolean + default: false + update_from: + description: Comma-separated published versions to install with Setup and update to the version above from the live feed + required: false + type: string + default: "" + pull_request: + paths: + - .github/workflows/desktop-windows-release-check.yaml + - fluxer_desktop/scripts/release-check.mjs + +permissions: + contents: read + +concurrency: + group: desktop-windows-release-check-${{ github.ref }}-${{ inputs.version || 'pr' }} + cancel-in-progress: false + +jobs: + check: + name: Check windows (${{ matrix.arch }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + os: windows-2025 + - arch: arm64 + os: windows-11-arm + env: + CHANNEL: ${{ inputs.channel || 'canary' }} + VERSION: ${{ inputs.version || '2026.1009.20411' }} + EXPECT_FAILURE: ${{ github.event_name == 'pull_request' && 'true' || inputs.expect_failure }} + UPDATE_FROM: ${{ inputs.update_from || '' }} + ARCH: ${{ matrix.arch }} + steps: + - name: Checkout source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + sparse-checkout: fluxer_desktop/scripts + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 24 + + - name: Check the portable build + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $work = Join-Path $env:RUNNER_TEMP 'portable' + New-Item -ItemType Directory -Force -Path $work | Out-Null + $zip = Join-Path $work 'portable.zip' + Invoke-WebRequest -Uri "https://pkgs.fluxer.com/desktop/$env:CHANNEL/win32/$env:ARCH/$env:VERSION/portable" -OutFile $zip + Expand-Archive -Path $zip -DestinationPath (Join-Path $work 'app') + $exe = Get-ChildItem -Path (Join-Path $work 'app') -Recurse -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1 + if ($null -eq $exe) { throw "The portable build holds no Fluxer executable" } + $output = & node fluxer_desktop/scripts/release-check.mjs --app $exe.DirectoryName --channel $env:CHANNEL --expect-renderer $env:VERSION --expect-source bundled --app-arg=--disable-gpu --workdir (Join-Path $work 'runs') --timeout-seconds 240 2>&1 + $code = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($env:EXPECT_FAILURE -eq 'true') { + if ($code -eq 0) { throw "Expected $env:VERSION to fail its release check, it passed" } + Write-Host "$env:VERSION fails its release check on windows $env:ARCH as expected" + exit 0 + } + if ($code -ne 0) { throw "The release check failed for $env:VERSION on windows $env:ARCH" } + + - name: Update installed builds from the live feed + if: env.UPDATE_FROM != '' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $channelDirs = @{ canary = 'fluxer_desktop_canary'; stable = 'fluxer_desktop' } + $dataDirs = @{ canary = 'fluxercanary'; stable = 'fluxer' } + $installRoot = Join-Path $env:LOCALAPPDATA $channelDirs[$env:CHANNEL] + $logPath = Join-Path (Join-Path $env:APPDATA $dataDirs[$env:CHANNEL]) 'logs\main.log' + $failures = @() + foreach ($from in ($env:UPDATE_FROM -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) { + Write-Host "== install $from, then update to $env:VERSION" + Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue + if (Test-Path $installRoot) { Remove-Item -Recurse -Force $installRoot } + Remove-Item -Recurse -Force (Split-Path $logPath -Parent) -ErrorAction SilentlyContinue + $setup = Join-Path $env:RUNNER_TEMP "setup-$from.exe" + Invoke-WebRequest -Uri "https://pkgs.fluxer.com/desktop/$env:CHANNEL/win32/$env:ARCH/$from/setup" -OutFile $setup + Start-Process -FilePath $setup -ArgumentList '--silent' -Wait + Start-Sleep -Seconds 5 + Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue + $exe = Get-ChildItem -Path (Join-Path $installRoot 'current') -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1 + if ($null -eq $exe) { $failures += "$from did not install"; continue } + Start-Process -FilePath $exe.FullName -ArgumentList '--disable-gpu' + $deadline = (Get-Date).AddMinutes(8) + $reported = $null + while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds 10 + if (Test-Path $logPath) { + $line = Select-String -Path $logPath -Pattern "The renderer reported its build: .*Desktop $([regex]::Escape($env:VERSION)), Web $([regex]::Escape($env:VERSION))" | Select-Object -Last 1 + if ($null -ne $line) { $reported = $line.Line; break } + } + } + $installed = Get-Content (Join-Path $installRoot 'current\sq.version') -Raw -ErrorAction SilentlyContinue + Write-Host "installed package after update: $installed" + if (Test-Path $logPath) { Get-Content $logPath | Select-String -Pattern 'Serving the renderer|reported its build|NativeModulePreflight|elf-update|shell update|Velopack' | Select-Object -Last 20 | ForEach-Object { Write-Host $_.Line } } + if ($null -eq $reported) { $failures += "$from did not reach Desktop and Web $env:VERSION" } else { Write-Host "$from updated: $reported" } + Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue + } + if ($failures.Count -gt 0) { throw ($failures -join "`n") } diff --git a/fluxer_desktop/scripts/release-check.mjs b/fluxer_desktop/scripts/release-check.mjs index fd1038921..de1d47de4 100644 --- a/fluxer_desktop/scripts/release-check.mjs +++ b/fluxer_desktop/scripts/release-check.mjs @@ -146,7 +146,10 @@ function stageApp(options, sandbox) { .map((name) => path.join(staged, name)) .find((candidate) => { const stats = fs.statSync(candidate); - return stats.isFile() && (stats.mode & 0o111) !== 0 && /^fluxer/iu.test(path.basename(candidate)); + const name = path.basename(candidate); + if (!stats.isFile() || !/^fluxer/iu.test(name)) return false; + if (process.platform === 'win32') return /\.exe$/iu.test(name) && !/_ExecutionStub\.exe$/iu.test(name); + return (stats.mode & 0o111) !== 0; }); if (executable == null) fail(`${options.app} holds no fluxer executable`); return {executable, portableRoot: path.join(staged, 'data')}; @@ -265,10 +268,28 @@ async function startUpdateFromRenderer(debugPort, timeoutMs) { fail('update: the shell never told the renderer that an update is available'); } -async function waitForRenderer({logPath, debugPort, version, timeoutMs, label}) { +function startupFailure(logPath) { + try { + const lines = fs.readFileSync(logPath, 'utf8').split(/\r?\n/u); + const index = lines.findIndex((line) => /\[NativeModulePreflight\] Fatal/u.test(line)); + return index < 0 ? null : lines.slice(index, index + 3).join(' '); + } catch { + return null; + } +} + +async function waitForRenderer({logPath, debugPort, version, timeoutMs, label, child = null}) { const deadline = Date.now() + timeoutMs; let observed = {log: observeLog(logPath), page: null}; while (Date.now() < deadline) { + if (child != null && (child.exitCode != null || child.signalCode != null)) { + const failure = startupFailure(logPath); + fail( + `${label}: the shell exited with ${child.exitCode ?? child.signalCode} before the renderer reported${ + failure == null ? '' : `, ${failure.trim()}` + }`, + ); + } observed = {log: observeLog(logPath), page: await servedVersionInPage(debugPort)}; if ( observed.page === version && @@ -282,7 +303,9 @@ async function waitForRenderer({logPath, debugPort, version, timeoutMs, label}) fail( `${label}: expected renderer ${version}, the shell serves ${observed.log.served?.version ?? 'nothing'} from ${ observed.log.served?.source ?? 'nowhere' - }, the window loads ${observed.page ?? 'nothing'} and reports ${observed.log.reportedBuild ?? 'nothing'}`, + }, the window loads ${observed.page ?? 'nothing'} and reports ${observed.log.reportedBuild ?? 'nothing'}${ + startupFailure(logPath) == null ? '' : `, ${startupFailure(logPath)}` + }`, ); } @@ -338,6 +361,7 @@ async function run() { version: firstVersion, timeoutMs: options.timeoutMs, label: 'launch', + child: options.update ? null : child, }); result.stages.push({stage: 'launch', ...first}); let final = first; diff --git a/tools/ci/src/desktop.rs b/tools/ci/src/desktop.rs index 248b3223a..bbe249e8f 100644 --- a/tools/ci/src/desktop.rs +++ b/tools/ci/src/desktop.rs @@ -144,6 +144,7 @@ enum DesktopStep { PackModules, PruneShellRenderer, VerifyBundledRendererLinux, + VerifyBundledRendererWindows, BuildElectronMain, InstallVelopackCli, BuildAppMacos, @@ -256,6 +257,7 @@ pub async fn run(args: BuildDesktopArgs) -> Result<()> { DesktopStep::PackModules => pack_modules_step(), DesktopStep::PruneShellRenderer => prune_shell_renderer_step(), DesktopStep::VerifyBundledRendererLinux => verify_bundled_renderer_linux_step(), + DesktopStep::VerifyBundledRendererWindows => verify_bundled_renderer_windows_step(), DesktopStep::BuildElectronMain => build_electron_main_step(), DesktopStep::InstallVelopackCli => install_velopack_cli_step(), DesktopStep::BuildAppMacos => build_app_step(DesktopBuildPlatform::Macos), @@ -1988,6 +1990,36 @@ fn verify_bundled_renderer_linux_step() -> Result<()> { Ok(()) } +fn verify_bundled_renderer_windows_step() -> Result<()> { + let channel = require_env("BUILD_CHANNEL")?; + let version = require_env("BUILD_VERSION")?; + let arch = require_env("ARCH")?; + let config = windows_package_config(&channel, &arch)?; + let unpacked = resolve_windows_unpacked_dir(&arch, &config.main_exe)?; + run_command( + CommandSpec::new("node") + .args(["scripts/release-check.mjs", "--app"]) + .arg(unpacked.as_os_str()) + .args([ + "--channel", + channel.as_str(), + "--expect-renderer", + version.as_str(), + "--expect-source", + "bundled", + "--package-origin", + "http://127.0.0.1:9", + "--app-arg=--disable-gpu", + "--timeout-seconds", + "240", + ]), + )?; + println!( + "The packaged {channel} shell {version} for windows {arch} passes its native module preflight and boots its bundled renderer" + ); + Ok(()) +} + fn split_modules_step() -> Result<()> { let renderer_dir = desktop_renderer_dir(); ensure_renderer_assets_ready(&renderer_dir)?;