Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/release_notes_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Just use the command lines
```
curl -L -o PowerInterviewAI-Setup-VERSION_PLACEHOLDER.exe https://github.com/PowerInterviewAI/client-app/releases/latest/download/PowerInterviewAI-Setup-VERSION_PLACEHOLDER.exe && start "" "PowerInterviewAI-Setup-VERSION_PLACEHOLDER.exe"
```
- MacOS
- MacOS (works on both Apple Silicon and Intel - picks the build matching `uname -m`)
```
curl -L -o Power.Interview.AI-VERSION_PLACEHOLDER-arm64.dmg https://github.com/PowerInterviewAI/client-app/releases/latest/download/Power.Interview.AI-VERSION_PLACEHOLDER-arm64.dmg && open "Power.Interview.AI-VERSION_PLACEHOLDER-arm64.dmg"
SUF=""; [ "$(uname -m)" = "arm64" ] && SUF="-arm64"; DMG="Power.Interview.AI-VERSION_PLACEHOLDER$SUF.dmg"; curl -L -o "$DMG" "https://github.com/PowerInterviewAI/client-app/releases/latest/download/$DMG" && open "$DMG"
```
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ jobs:
run: ${{ matrix.build_command }}
shell: bash

# The mac job packages x64 and arm64 from one node_modules, and pnpm only materialises
# the runner's own architecture, so a missing native binary is invisible until a user
# launches the artifact. Fail the release here instead.
- name: Verify packaged native dependencies
run: node test/verify-packaged-sharp.mjs release
shell: bash

- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
Expand Down
62 changes: 62 additions & 0 deletions .github/workflows/verify-native-deps.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Verify native dependencies

# Packaging bugs in sharp's per-architecture binaries do not surface until a user launches
# the app, and release.yml only builds on demand. This runs the same packaging on a real
# macOS runner and asserts both mac architectures carry a working sharp - no version bump,
# no tag, no publish.
on:
push:
branches:
- 'verify/**'
workflow_dispatch:

jobs:
verify:
name: Package and verify on ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
pack_command: pnpm exec electron-builder --mac --dir --arm64 --x64 --publish never
- os: windows-latest
pack_command: pnpm exec electron-builder --win --dir --x64 --publish never
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Report host and installed sharp binaries
run: |
echo "runner arch: $(node -p 'process.arch') on $(node -p 'process.platform')"
ls node_modules/.pnpm | grep '^@img' || echo 'no @img packages installed'
shell: bash

- name: Build renderer and main process
run: pnpm run electron:build-main && pnpm run build

- name: Package application
run: ${{ matrix.pack_command }}
shell: bash

- name: Verify packaged native dependencies
run: node test/verify-packaged-sharp.mjs release
shell: bash

# Loading sharp through ELECTRON_RUN_AS_NODE never starts Electron proper, so it cannot
# catch a failure that only shows up once the main process boots.
- name: Smoke test packaged app launch
run: node test/smoke-packaged-launch.mjs release
shell: bash
14 changes: 13 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@
"dist/**/*",
"package.json"
],
"asarUnpack": [
"**/node_modules/sharp/**/*",
"**/node_modules/@img/**/*"
],
"directories": {
"buildResources": "build",
"output": "release"
Expand All @@ -109,7 +113,11 @@
]
}
],
"icon": "build/icon.ico"
"icon": "build/icon.ico",
"files": [
"!**/node_modules/@img/*darwin*/**",
"!**/node_modules/@img/*linux*/**"
]
},
"mac": {
"target": [
Expand All @@ -129,6 +137,10 @@
}
],
"icon": "build/icon.png",
"files": [
"!**/node_modules/@img/*win32*/**",
"!**/node_modules/@img/*linux*/**"
],
"category": "public.app-category.productivity",
"minimumSystemVersion": "14.4.0",
"identity": "-",
Expand Down
12 changes: 12 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,15 @@ allowBuilds:
electron-winstaller: true
esbuild: true
sharp: true

# The macOS runner is arm64 but electron-builder packages both mac arches from one
# node_modules, so sharp's per-arch optional deps must all be materialised at install
# time or the x64 artifact ships without its native binary.
supportedArchitectures:
os:
- win32
- darwin
- current
cpu:
- x64
- arm64
90 changes: 90 additions & 0 deletions test/packaged-apps.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Locating packaged apps in an electron-builder output directory, shared by the checks that
* run against a build rather than against source.
*/
import fs from 'node:fs';
import path from 'node:path';

const MACHO_64 = 0xfeedfacf;
const MACHO_CPU = { 0x01000007: 'x64', 0x0100000c: 'arm64' };
const PE_MACHINE = { 0x8664: 'x64', 0xaa64: 'arm64' };

/** Architecture a Mach-O or PE binary was built for, or null if unrecognised. */
export function readArch(binary) {
const fd = fs.openSync(binary, 'r');
const head = Buffer.alloc(64);
fs.readSync(fd, head, 0, 64, 0);
try {
if (head.readUInt32LE(0) === MACHO_64) return MACHO_CPU[head.readUInt32LE(4)] ?? null;
if (head.toString('ascii', 0, 2) === 'MZ') {
const peOffset = head.readUInt32LE(0x3c);
const coff = Buffer.alloc(6);
fs.readSync(fd, coff, 0, 6, peOffset);
return PE_MACHINE[coff.readUInt16LE(4)] ?? null;
}
return null;
} finally {
fs.closeSync(fd);
}
}

/**
* Every packaged app under `dir`, as { label, platform, arch, executable, resources }.
*
* Paths are absolute: callers hand them to require(), where a bare relative specifier would be
* read as a package name rather than a location on disk.
*/
export function findApps(dir) {
const root = path.resolve(dir);
const apps = [];

const add = (bundle, executable, resources) => {
apps.push({
label: path.relative(root, bundle),
platform: executable.endsWith('.exe') ? 'win32' : 'darwin',
arch: readArch(executable),
executable,
resources,
});
};

const walk = (current, depth) => {
if (depth > 3) return;
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const full = path.join(current, entry.name);
if (entry.name.endsWith('.app')) {
const macos = path.join(full, 'Contents', 'MacOS');
const [binary] = fs.existsSync(macos) ? fs.readdirSync(macos) : [];
if (binary) {
add(full, path.join(macos, binary), path.join(full, 'Contents', 'Resources'));
}
continue;
}
if (entry.name.endsWith('-unpacked')) {
const [exe] = fs.readdirSync(full).filter((f) => f.endsWith('.exe'));
if (exe) add(full, path.join(full, exe), path.join(full, 'resources'));
continue;
}
walk(full, depth + 1);
}
};

walk(root, 0);
return apps;
}

/** Console reporter shared by the packaged checks. */
export function createChecker() {
const failures = [];
return {
failures,
check(name, ok) {
console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}`);
if (!ok) failures.push(name);
},
skip(name) {
console.log(` skip ${name}`);
},
};
}
104 changes: 104 additions & 0 deletions test/smoke-packaged-launch.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Launches each packaged app for real and fails if the main process dies or reports an
* uncaught exception.
*
* verify-packaged-sharp.mjs loads sharp through ELECTRON_RUN_AS_NODE, which proves the binary
* resolves but never starts Electron proper. A native module that is missing, built for the
* wrong architecture, or unsigned takes the app down at startup instead - the failure users
* actually see, as a "A JavaScript error occurred in the main process" dialog. That dialog also
* keeps the process alive, so staying up is not on its own evidence of health and the output
* has to be checked too.
*
* Run against the electron-builder output directory: node test/smoke-packaged-launch.mjs release
*/
import { execFileSync, spawn } from 'node:child_process';
import path from 'node:path';

import { createChecker, findApps } from './packaged-apps.mjs';

const SETTLE_MS = 20000;
const FATAL = /Uncaught Exception|A JavaScript error occurred|Cannot find module|dlopen|code signature|Could not load the "sharp" module/i;

const { check, skip, failures } = createChecker();

function launch(app) {
return new Promise((resolve) => {
// No extra CLI flags: a packaged Electron binary rejects unrecognised leading-dash options
// outright, so anything passed here would test the launcher rather than the app.
const child = spawn(app.executable, [], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ELECTRON_ENABLE_LOGGING: '1' },
// Own process group, so teardown can take Electron's GPU and renderer children with it.
detached: process.platform !== 'win32',
});

const startedAt = Date.now();
let output = '';
let exited = null;
child.stdout.on('data', (d) => (output += d));
child.stderr.on('data', (d) => (output += d));
child.on('error', (e) => (output += `spawn error: ${e.message}\n`));
child.on('exit', (code, signal) => (exited = { code, signal, afterMs: Date.now() - startedAt }));

setTimeout(() => {
const alive = exited === null;
// Electron's children outlive a kill aimed at the parent alone, and a survivor holds the
// single instance lock - which makes the *next* app under test quit immediately and look
// like a failure. Tear down the whole tree.
if (alive) {
try {
if (process.platform === 'win32') {
execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
} else {
process.kill(-child.pid, 'SIGKILL');
}
} catch {
child.kill('SIGKILL');
}
}
setTimeout(() => resolve({ output, exited: alive ? null : exited }), 1500);
}, SETTLE_MS);
});
}

const releaseDir = path.resolve(process.argv[2] ?? 'release');
const apps = findApps(releaseDir);
if (apps.length === 0) {
console.error(`No packaged app found under ${releaseDir}`);
process.exit(1);
}

for (const app of apps) {
console.log(`\n# ${app.label} (${app.platform}-${app.arch})`);

const { output, exited } = await launch(app);
const fatal = output.match(FATAL);

if (exited && /Bad CPU type|Exec format error|ENOEXEC/i.test(output)) {
skip(`launch (host is ${process.arch}, artifact is ${app.arch}, no translation available)`);
continue;
}

check('starts without an uncaught exception in the main process', fatal === null);
check('main process is still running after startup', exited === null);

if (fatal !== null || exited !== null) {
if (exited) {
console.error(` exited: code=${exited.code} signal=${exited.signal} after ${exited.afterMs}ms`);
// The single instance lock is the only path that quits this cleanly this early, and it
// means something else on the machine already holds it rather than the build being bad.
if (exited.code === 0 && exited.afterMs < 2000) {
console.error(' quit immediately with no error: another instance likely holds the single instance lock');
}
}
for (const line of output.trim().split('\n').slice(-25)) console.error(` ${line}`);
}
}

if (failures.length > 0) {
console.error(`\n${failures.length} check(s) failed:`);
for (const failure of failures) console.error(` - ${failure}`);
process.exit(1);
}

console.log('\nAll packaged apps launched cleanly.');
Loading
Loading