From 22157ef31f1ec69811c5112d3fff3378d83f84f6 Mon Sep 17 00:00:00 2001 From: Pierre Date: Thu, 16 Jul 2026 18:11:48 +0200 Subject: [PATCH] fix: stabilize silent Windows installer smoke --- build/installer.nsh | 8 ++++++++ docs-web/developer/building-from-source.md | 16 +++++++++------- docs/deployment/electron-desktop.md | 4 ++-- scripts/smoke-installed-electron.mjs | 5 ++++- tests/backend/electron-builder-config.test.ts | 11 ++++++++++- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/build/installer.nsh b/build/installer.nsh index d990ffeb26..74941b8886 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -8,6 +8,14 @@ !macroend Function CodeUxBetaPage + # Silent release and deployment installs do not have an interactive desktop. Skip the custom + # page before nsDialogs loads; normal installers continue into the beta notice below. + IfSilent CodeUxBetaPageSilent CodeUxBetaPageInteractive + +CodeUxBetaPageSilent: + Abort + +CodeUxBetaPageInteractive: nsDialogs::Create 1018 Pop $0 diff --git a/docs-web/developer/building-from-source.md b/docs-web/developer/building-from-source.md index 7734129a3f..391cb0106f 100644 --- a/docs-web/developer/building-from-source.md +++ b/docs-web/developer/building-from-source.md @@ -172,13 +172,15 @@ The installed-Electron smoke uses each platform's native package and waits until backend and renderer are ready and the readiness marker is durably written. Linux and Windows then require the isolated app probe to exit with code zero. On Windows the harness passes the NSIS silent-install arguments verbatim so the required final `/D=` destination remains unquoted even -when that destination contains spaces. The exact transient `0xC0000005` termination sometimes seen -while a GitHub runner inspects a newly produced unsigned installer receives bounded retries after -1.5, 5, and 15 seconds with fresh destinations; other failures are not retried. On macOS the harness -accepts the DMG's embedded MIT license through `hdiutil` stdin, validates readiness, then owns probe -teardown with `SIGTERM` and a bounded `SIGKILL` fallback because Electron/AppKit can defer both -Electron and Node exit paths. Production Electron shutdowns still drain the embedded runtime -normally. +when that destination contains spaces, and it explicitly selects current-user installation. The +interactive beta notice aborts before initializing `nsDialogs` during silent installs while +remaining visible in the normal installer. The exact transient `0xC0000005` termination sometimes +seen while a GitHub runner inspects a newly produced unsigned installer receives bounded retries +after 1.5, 5, and 15 seconds with fresh destinations; other failures are not retried. On macOS the +harness accepts the DMG's embedded MIT license through `hdiutil` stdin, validates readiness, then +owns probe teardown with `SIGTERM` and a bounded `SIGKILL` fallback because Electron/AppKit can +defer both Electron and Node exit paths. Production Electron shutdowns still drain the embedded +runtime normally. They must also include `assets/models-dev/catalog.json`. The automatic token-pricing path reads this snapshot beside the compiled runtime; without it, known models can appear unpriced only in the desktop build. Electron packaging tests pin both runtime assets and a representative GPT-5.5 catalogue rate. diff --git a/docs/deployment/electron-desktop.md b/docs/deployment/electron-desktop.md index a73f6c2815..cf57ffea3b 100644 --- a/docs/deployment/electron-desktop.md +++ b/docs/deployment/electron-desktop.md @@ -67,7 +67,7 @@ macOS DMG builds include the MIT license resource through `build/license_en.txt` - `pnpm run electron:dist:win`: build Windows targets. - `pnpm run electron:benchmark:runtime`: launch Electron with an isolated temporary user profile, navigate dashboard routes, probe backend endpoints, and write route/API/renderer/runtime metrics under `.cache/electron-runtime-benchmark/`. - `pnpm run electron:benchmark:win`: build Windows installers with `normal` and `store` compression and write timing/size data to `release/electron-benchmark/summary.json`. -- `pnpm run electron:smoke-installed`: select the native package in `release/electron/` whose artifact name matches the current `package.json` version, install it, start that installed app with an isolated profile, and wait until its backend and renderer are ready. Linux and Windows require the isolated app probe to exit with code zero. macOS validates the durably written readiness marker, then the parent smoke harness terminates only that validated probe with `SIGTERM` and a bounded `SIGKILL` fallback because Electron/AppKit can defer both `app.exit()` and `process.exit()` after readiness. Linux requires passwordless `sudo` and `xvfb-run`, as provided by the release runners. Windows launches the silent NSIS installer with verbatim arguments because NSIS requires its final `/D=` destination segment to remain unquoted even when the path contains spaces. GitHub Windows runners can transiently terminate a newly produced unsigned NSIS installer with `0xC0000005` while host security inspection settles, so only that exact status receives bounded retries after 1.5, 5, and 15 seconds, each with a fresh destination; every other installer failure remains immediate. macOS supplies affirmative input to `hdiutil` so the CI-owned smoke can mount a DMG containing the embedded MIT license without an interactive terminal. +- `pnpm run electron:smoke-installed`: select the native package in `release/electron/` whose artifact name matches the current `package.json` version, install it, start that installed app with an isolated profile, and wait until its backend and renderer are ready. Linux and Windows require the isolated app probe to exit with code zero. macOS validates the durably written readiness marker, then the parent smoke harness terminates only that validated probe with `SIGTERM` and a bounded `SIGKILL` fallback because Electron/AppKit can defer both `app.exit()` and `process.exit()` after readiness. Linux requires passwordless `sudo` and `xvfb-run`, as provided by the release runners. Windows explicitly selects the current-user NSIS mode and passes the silent install arguments verbatim because NSIS requires its final `/D=` destination segment to remain unquoted even when the isolated path contains spaces. The custom beta notice is skipped before `nsDialogs` initialization during silent installs; it remains visible for normal interactive installation. GitHub Windows runners can transiently terminate a newly produced unsigned NSIS installer with `0xC0000005` while host security inspection settles, so only that exact status receives bounded retries after 1.5, 5, and 15 seconds, each with a fresh destination; every other installer failure remains immediate. macOS supplies affirmative input to `hdiutil` so the CI-owned smoke can mount a DMG containing the embedded MIT license without an interactive terminal. - `pnpm run electron:install-deps`: rebuild native app dependencies for Electron. The release output is written to `release/electron/`. @@ -129,7 +129,7 @@ Use `.github/workflows/release.yml` for published desktop releases. It is the la The no-secret release-candidate package lane is part of `.github/workflows/ci.yml`, named `Code UX CI Pipeline`. It runs for `main` validation and manual dispatches after package smoke, keeping the full desktop package proof out of the routine `dev` lane. -The `10 Release Candidate / desktop package` matrix starts as soon as the package smoke job passes, so desktop packaging can run beside the E2E and orchestration matrices instead of waiting for them to finish. It downloads the shared `codeux-build-linux` artifact, installs the cached Electron binary, rebuilds Electron native dependencies, prepares runtime assets, and runs Electron Builder directly with `--linux`, `--mac`, or `--win` plus `--publish never`. After compilation, every native runner installs its candidate—the Linux `.deb`, Windows NSIS `.exe`, or macOS app copied from the `.dmg`—and starts that installed copy with an isolated home and dashboard port. The Windows smoke preserves NSIS's required unquoted, final `/D=` destination segment when spawning the installer. It treats only the known transient `0xC0000005` runner termination as retryable, using three progressively longer waits and a fresh destination each time so a short host-security scan cannot make an otherwise valid candidate flaky. The macOS smoke accepts the DMG's embedded MIT license through `hdiutil` stdin before mounting. Success requires the packaged backend to start, the dashboard renderer to finish loading, and an atomic readiness marker to be written. Linux and Windows then require a zero-code probe exit; on macOS the parent harness owns teardown after validation, using `SIGTERM` with a short `SIGKILL` fallback so upstream AppKit exit deferral cannot turn a healthy installed app into a false failure. Built native artifacts are uploaded whenever packaging succeeded, even if the installed-app smoke fails, preserving evidence for release debugging. Production shutdowns remain unchanged and still drain the embedded server. The package smoke job that precedes it runs `node scripts/verify-release-install.mjs` with `CODE_UX_SKIP_RELEASE_INSTALL_BUILD=1`, so the npm tarball install check uses the same compiled artifact instead of rebuilding. +The `10 Release Candidate / desktop package` matrix starts as soon as the package smoke job passes, so desktop packaging can run beside the E2E and orchestration matrices instead of waiting for them to finish. It downloads the shared `codeux-build-linux` artifact, installs the cached Electron binary, rebuilds Electron native dependencies, prepares runtime assets, and runs Electron Builder directly with `--linux`, `--mac`, or `--win` plus `--publish never`. After compilation, every native runner installs its candidate—the Linux `.deb`, Windows NSIS `.exe`, or macOS app copied from the `.dmg`—and starts that installed copy with an isolated home and dashboard port. The Windows smoke selects current-user installation, preserves NSIS's required unquoted final `/D=` destination segment, and skips the interactive beta page before its dialog plugin is initialized. It treats only the known transient `0xC0000005` runner termination as retryable, using three progressively longer waits and a fresh destination each time so a short host-security scan cannot make an otherwise valid candidate flaky. The macOS smoke accepts the DMG's embedded MIT license through `hdiutil` stdin before mounting. Success requires the packaged backend to start, the dashboard renderer to finish loading, and an atomic readiness marker to be written. Linux and Windows then require a zero-code probe exit; on macOS the parent harness owns teardown after validation, using `SIGTERM` with a short `SIGKILL` fallback so upstream AppKit exit deferral cannot turn a healthy installed app into a false failure. Built native artifacts are uploaded whenever packaging succeeded, even if the installed-app smoke fails, preserving evidence for release debugging. Production shutdowns remain unchanged and still drain the embedded server. The package smoke job that precedes it runs `node scripts/verify-release-install.mjs` with `CODE_UX_SKIP_RELEASE_INSTALL_BUILD=1`, so the npm tarball install check uses the same compiled artifact instead of rebuilding. Release-candidate packaging sets `CSC_IDENTITY_AUTO_DISCOVERY=false` for unsigned Electron packaging and passes `--publish never` to Electron Builder. It does not require provider API keys, npm publishing credentials, Docker credentials, GitHub Release events, or real project state. When Electron output exists, the workflow uploads files from `release/electron/` as workflow artifacts only; it does not publish to npm or attach files to a GitHub Release. diff --git a/scripts/smoke-installed-electron.mjs b/scripts/smoke-installed-electron.mjs index 09e866d084..9b1c349482 100644 --- a/scripts/smoke-installed-electron.mjs +++ b/scripts/smoke-installed-electron.mjs @@ -81,7 +81,10 @@ async function installWindowsCandidate(temporaryRoot) { // NSIS requires /D= to be the final, completely unquoted command-line segment even when the // destination contains spaces. Node otherwise quotes that argument on Windows and the generated // installer can terminate before extracting the application. - const result = runResult(installer, ["/S", `/D=${installDirectory}`], { + // Force the current-user mode used by the shipped installer and keep the smoke isolated under + // its temporary root. The custom NSIS beta page explicitly aborts in silent mode, so the + // release probe never initializes interactive nsDialogs while no desktop is attached. + const result = runResult(installer, ["/S", "/currentuser", `/D=${installDirectory}`], { windowsVerbatimArguments: true, }); if (result.status === 0) { diff --git a/tests/backend/electron-builder-config.test.ts b/tests/backend/electron-builder-config.test.ts index 2c160bf474..330fdc6d25 100644 --- a/tests/backend/electron-builder-config.test.ts +++ b/tests/backend/electron-builder-config.test.ts @@ -127,10 +127,14 @@ describe("electron-builder packaged defaults", () => { path.join(process.cwd(), "scripts", "smoke-installed-electron.mjs"), "utf8", ); + const installerInclude = fs.readFileSync( + path.join(process.cwd(), "build", "installer.nsh"), + "utf8", + ); const mainProcessSource = fs.readFileSync(path.join(process.cwd(), "src/electron/main.ts"), "utf8"); expect(installerSmoke).toContain('findArtifact(".deb")'); - expect(installerSmoke).toContain('["/S", `/D=${installDirectory}`]'); + expect(installerSmoke).toContain('["/S", "/currentuser", `/D=${installDirectory}`]'); expect(installerSmoke).toContain("windowsVerbatimArguments: true"); expect(installerSmoke).toContain("WINDOWS_ACCESS_VIOLATION"); expect(installerSmoke).toContain("WINDOWS_INSTALL_RETRY_DELAYS_MS = [1_500, 5_000, 15_000]"); @@ -152,6 +156,11 @@ describe("electron-builder packaged defaults", () => { expect(installerSmoke).toContain("terminateValidatedMacProbe"); expect(installerSmoke).toContain("stopped.exit.code === 0"); expect(installerSmoke).toContain("stopped.exit.signal === stopped.termination"); + expect(installerInclude).toContain( + "IfSilent CodeUxBetaPageSilent CodeUxBetaPageInteractive", + ); + expect(installerInclude).toContain("CodeUxBetaPageSilent:\n Abort"); + expect(installerInclude).toContain("CodeUxBetaPageInteractive:\n nsDialogs::Create 1018"); expect(mainProcessSource).toContain('window.webContents.once("did-finish-load"'); expect(mainProcessSource).toContain("writeElectronStartupSmoke"); expect(mainProcessSource).toContain('CODE_UX_ELECTRON_STARTUP_SMOKE_EXIT === "1"');