Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions .github/workflows/windows-portable-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
name: Windows portable E2E

on:
workflow_dispatch:
inputs:
require_taskbar_pin:
description: "Fail unless the selected Windows runner can pin the app"
required: false
default: false
type: boolean
pull_request:
paths:
- "apps/desktop/package.json"
- "apps/desktop/test/auto-update.test.mjs"
- "scripts/windows-portable-e2e.ps1"
- ".github/workflows/windows-portable-e2e.yml"

permissions:
contents: read

concurrency:
group: windows-portable-e2e-${{ github.ref }}
cancel-in-progress: true

jobs:
portable:
name: Windows portable package and taskbar smoke
runs-on: windows-latest
timeout-minutes: 60
Comment on lines +28 to +29
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v7

- uses: pnpm/action-setup@v6

- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm

- uses: dtolnay/rust-toolchain@stable

- uses: Swatinem/rust-cache@v2

- name: Build package inputs
run: |
set -euo pipefail
cargo build --release --locked -p host-core &
host_build_pid=$!
trap 'kill "$host_build_pid" 2>/dev/null || true' EXIT

pnpm install --frozen-lockfile
pnpm --filter '@pi-desktop/desktop^...' --fail-if-no-match build

wait "$host_build_pid"
trap - EXIT

- name: Build Windows packages
run: pnpm --filter @pi-desktop/desktop run dist:win -- --x64

- name: Verify Windows release artifacts
shell: pwsh
run: |
$release = "apps/desktop/release"
$nsis = @(Get-ChildItem -LiteralPath $release -Filter "PI-Desktop-Setup-*.exe" -File)
$portable = @(Get-ChildItem -LiteralPath $release -Filter "PI-Desktop-Portable-*.exe" -File)
$feedPath = Join-Path $release "latest.yml"
if ($nsis.Count -ne 1) {
throw "Expected exactly one NSIS installer, found $($nsis.Count)"
}
if ($portable.Count -ne 1) {
throw "Expected exactly one portable executable, found $($portable.Count)"
}
if (-not (Test-Path -LiteralPath $feedPath -PathType Leaf)) {
throw "NSIS update feed was not produced: $feedPath"
}
foreach ($artifact in @($nsis + $portable)) {
if ($artifact.Name -match "\s") {
throw "Windows artifact name contains whitespace: $($artifact.Name)"
}
}
$feed = Get-Content -LiteralPath $feedPath -Raw
if ($feed -notmatch "PI-Desktop-Setup-") {
throw "latest.yml does not reference the NSIS installer"
}
if ($feed -match "PI-Desktop-Portable-") {
throw "latest.yml must not reference the portable executable"
}
Write-Host "NSIS_ARTIFACT=$($nsis[0].Name)"
Write-Host "PORTABLE_ARTIFACT=$($portable[0].Name)"
Write-Host "UPDATE_FEED=$feedPath"

- name: Preserve first portable build
run: |
set -euo pipefail
mkdir -p apps/desktop/release/build-a
mv apps/desktop/release/PI-Desktop-Portable-*.exe apps/desktop/release/build-a/

- name: Build second portable version from the same source
working-directory: apps/desktop
run: >-
pnpm exec electron-builder --win portable --x64 --publish never
-c.extraMetadata.version=0.15.2-test-b
-c.directories.output=release/build-b

- name: Run portable extraction and taskbar smoke
shell: pwsh
env:
REQUIRE_TASKBAR_PIN: ${{ inputs.require_taskbar_pin }}
run: |
$portable = Get-ChildItem "apps/desktop/release/build-a/PI-Desktop-Portable-*.exe" |
Select-Object -First 1
$secondPortable = Get-ChildItem "apps/desktop/release/build-b/PI-Desktop-Portable-*.exe" |
Select-Object -First 1
if ($null -eq $portable -or $null -eq $secondPortable) {
throw "Both Windows portable artifacts were not produced"
}
$arguments = @{
PortableExe = $portable.FullName
SecondPortableExe = $secondPortable.FullName
}
if ([string]::Equals([string]$env:REQUIRE_TASKBAR_PIN, "true", [StringComparison]::OrdinalIgnoreCase)) {
$arguments.RequireTaskbarPin = $true
}
$output = @()
$scriptSucceeded = $false
try {
$output = @(& ./scripts/windows-portable-e2e.ps1 @arguments *>&1)
$scriptSucceeded = $?
} catch {
$output += $_
}
$outputText = @($output | ForEach-Object { "$_" })
$outputText |
Tee-Object -FilePath windows-portable-e2e.log
if (-not $scriptSucceeded -or $outputText -notcontains "WINDOWS_PORTABLE_E2E=PASS") {
exit 1
}

- name: Upload portable smoke logs
if: always()
uses: actions/upload-artifact@v4
with:
name: windows-portable-e2e-release
if-no-files-found: warn
retention-days: 7
path: |
apps/desktop/release/build-a/PI-Desktop-Portable-*.exe
apps/desktop/release/build-b/PI-Desktop-Portable-*.exe
apps/desktop/release/PI-Desktop-Setup-*.exe
apps/desktop/release/PI-Desktop-Setup-*.exe.blockmap
apps/desktop/release/latest.yml
windows-portable-e2e.log
3 changes: 2 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,8 @@
},
"portable": {
"artifactName": "PI-Desktop-Portable-${version}.${ext}",
"requestExecutionLevel": "user"
"requestExecutionLevel": "user",
"unpackDirName": "PI-Desktop-Portable"
},
"deb": {
"packageName": "pi-desktop",
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/test/auto-update.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,11 @@ test("packaging publishes an electron-updater feed for GitHub Releases", () => {
"PI-Desktop-Portable-${version}.${ext}",
);
assert.equal(pkg.build.portable.requestExecutionLevel, "user");
assert.equal(
pkg.build.portable.unpackDirName,
"PI-Desktop-Portable",
"portable extraction path stays stable for Windows taskbar identity",
);
// The upload step must carry every updater feed, and the release publishes
// all platforms unfiltered (D126/D285).
assert.match(releaseWorkflowSource, /release\/\*\.zip/);
Expand Down
22 changes: 20 additions & 2 deletions docs/adr/0197-windows-portable-exe.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ ownership or to the NSIS in-app update lane.
electron-builder's `portable` target produces a user-level self-extracting
executable. It does not write `latest.yml`. Applying the NSIS updater to a
portable run would launch the installer and convert the no-install copy into
an installed one.
an installed one. In electron-builder 26, the default extraction directory is
a build-time ksuid: it is stable for one artifact, but changes between builds.
The taskbar fix therefore needs an explicit name, while also accounting for
the portable launcher's cleanup and shared-directory behavior.

## Decision

Expand All @@ -32,6 +35,12 @@ an installed one.
quit-and-install.
6. User data, logs, and secrets stay in the existing application data
directory. This decision does not introduce a beside-the-exe profile.
7. The portable target uses the fixed per-user temp directory name
`PI-Desktop-Portable` for extraction. The packaged executable therefore
resolves to `%TEMP%\PI-Desktop-Portable\PI-Desktop.exe` while running and
on the next launch, including across portable builds. The launcher still
removes that directory before extraction and after the app exits; this is a
stable runtime identity, not a persistent installation or pin target.

## Consequences

Expand All @@ -41,9 +50,18 @@ an installed one.
replace the portable file themselves.
- NSIS in-app updates, hashes, and feed ownership are unchanged.
- The portable process still unpacks application files under the Windows temp
directory for that launch. Whitelisting applies to the downloaded portable
directory for that launch, using the stable `PI-Desktop-Portable` directory
name. The launcher deletes the directory on exit, so a taskbar pin that
targets the unpacked executable may be missing or show a blank icon while
the app is stopped; the next launch recreates the path and restores the
running-window identity. Whitelisting applies to the downloaded portable
executable; a policy that also blocks temp-directory execution may still
require the NSIS install.
- All portable wrappers for one user, including different versions, share the
extraction directory. Do not launch two portable wrappers concurrently or
replace one while another is running: the second launcher can remove or
overwrite the first launch's files before Electron's single-instance lock
is acquired. An installed NSIS copy uses a separate tree.

## Alternatives considered

Expand Down
49 changes: 35 additions & 14 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -11279,28 +11279,49 @@ are withdrawn with ADR 0165.

#### E2E-211: Windows portable exe launches without an installer (D364)

- **Preconditions**: A Windows x64 tag or `dist:win` package has produced both
`PI-Desktop-Setup-<version>.exe` and `PI-Desktop-Portable-<version>.exe` from
the shared electron-builder config; a clean user profile is available; the
account is a standard user without administrator elevation.
- **Steps**: 1) Inspect the release directory and `latest.yml`. 2) Launch the
portable exe without running the NSIS installer. 3) Confirm the process
environment includes `PORTABLE_EXECUTABLE_FILE`. 4) Invoke Check for Updates.
5) Confirm Settings → Info offers the releases page rather than Restart to
update. 6) Quit and relaunch the same portable file.
- **Preconditions**: Windows x64 portable packages from two builds are
available (A and B), alongside the matching NSIS package and `latest.yml`;
a clean user profile is available; the account is a standard user without
administrator elevation.
- **Steps**: 1) Inspect the release directory and `latest.yml`. 2) Launch
portable package A without running the NSIS installer. 3) Confirm the process
environment includes `PORTABLE_EXECUTABLE_FILE` and the unpacked app path is
`%TEMP%\PI-Desktop-Portable\PI-Desktop.exe`. 4) Pin the running app to the
taskbar and record the pinned target and icon. 5) Quit A before launching
anything else; confirm the launcher removes the extraction directory and
record whether the stopped pin is missing or blank. 6) Relaunch A and confirm
the same path is recreated, the running icon is correct, and the pin resolves
again. 7) After A has exited, launch package B and confirm it uses the same
fixed path. 8) In an isolated run, start B while A is still running and
capture the shared-directory removal/overwrite risk; terminate both and mark
concurrent portable wrappers unsupported. 9) Invoke Check for Updates. 10)
Confirm Settings → Info offers the releases page rather than Restart to update.
- **Expected**: Both Windows artifacts are space-free and uploaded. `latest.yml`
points at the NSIS installer only. The portable exe starts without a setup
points at the NSIS installer only. Each portable exe starts without a setup
wizard or administrator prompt, uses the existing application data directory,
and reports update mode `manual`. An available update does not download or
run `PI-Desktop-Setup-<version>.exe`. Relaunch restores sessions from that
same profile.
run `PI-Desktop-Setup-<version>.exe`. The explicit unpack path is stable
across builds and launches, so the running taskbar grouping and icon lookup
use the same stable path rather than a new build-time ksuid; running-window
grouping remains owned by the app user model ID. The launcher removes
that path on exit, so the stopped pin may be unavailable or blank; relaunch
recreates the path and restores the running icon/pin target. A second portable
wrapper must not run concurrently because it can remove or overwrite the
first wrapper's shared extraction directory. Relaunch restores sessions from
the same profile.
- **Specs linked**: `01-product/01-product-scope.md`,
`06-delivery/06-release-runbook.md`, `03-runtime/07-process-model.md`,
ADR 0197 / D364
- **Acceptance**: Quality (release packaging)
- **Milestone**: M6+
- **Status**: Unit/source-contract covered (`auto-update.test.mjs`); native
Windows launch remains runner validation (run only in a capable environment when this surface changes)
- **Status**: Packaging contract is unit-covered; `Windows portable E2E` workflow
automates the native NSIS/portable artifact checks, extraction, cleanup,
relaunch, cross-build, and concurrency smoke. The hosted-runner default records
taskbar pin qualification as `NOT_RUN` when no Explorer pin verb is available.
Provision an interactive Windows runner for this workflow (or run the script
there) and enable `require_taskbar_pin=true` to complete native taskbar
qualification; the default `windows-latest` runner does not provide that
capability reliably.

#### E2E-213: The first Composer model menu paint keeps configured aliases

Expand Down
9 changes: 8 additions & 1 deletion docs/spec/06-delivery/06-release-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,14 @@ The portable Windows target does not write `latest.yml`. Packaged portable
runs use notify-and-link delivery (`PORTABLE_EXECUTABLE_FILE`); NSIS keeps
the in-app download and quit-and-install lane. Data stays in the existing
application data directory. Portable requests user execution level, so launch
does not require administrator rights.
does not require administrator rights. Its self-extracted app uses the fixed
per-user temp path `%TEMP%\PI-Desktop-Portable\PI-Desktop.exe`, so the running
executable identity is stable across builds and launches. The launcher removes
that directory before extraction and after exit; a pin targeting the unpacked
file can therefore be missing or blank while the app is stopped and is only
re-established when the app launches again. All portable wrappers for one user
share the path, so do not run two versions concurrently; an installed NSIS copy
uses a separate tree.

RPM targets pass `_build_id_links none` to FPM. Bundled Electron binaries live
under `/opt/PI-Desktop`; omitting global `/usr/lib/.build-id` links prevents
Expand Down
Loading
Loading