fix(desktop): green Windows smoke + Linux AppImage PG packaging checks (#2138)
## Summary - **Windows CI:** run the embedded Postgres smoke as non-admin `fusion-pg` (with profile prewarm) so elevated `windows-latest` runners stop failing with PostgreSQL’s admin-token refusal. Packaging still runs as the job user. - **Linux AppImage:** add a packaging content verifier for `main-bootstrap`, `@embedded-postgres` natives, and `omp-runtime` dist entrypoints; wire it into `release.yml`, `test-release.yml`, and the advisory **Desktop packaging** PR lane (after `electron-builder --dir`). - Fix eslint `no-undef` on bare `URL` in the verifier script (was red on #2131). ## Context Desktop packaging on Ubuntu was mostly green; Windows desktop builds and the AppImage packaging PR (#2131 lint) were the remaining red paths. The win-pg-diag pivot (run smoke as non-admin) proved green on CI; this ports that approach without removing main’s elevated-token product path for end-user “Run as administrator” cases (smoke simply does not take that path when the process is non-admin). ## Test plan - [x] `pnpm --filter @fusion/desktop exec vitest run src/__tests__/release-workflow.test.ts` - [x] `pnpm exec eslint scripts/verify-desktop-linux-pg-packaging.mjs` - [ ] Desktop packaging workflow on this PR - [ ] Desktop Windows Build (workflow_dispatch) - [ ] Confirm #2131 supersession if this lands the same AppImage checks <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Strengthened Linux desktop AppImage validation to confirm embedded PostgreSQL artifacts, required binaries, symlink hydration, and the expected app entrypoints are present after packaging. * Improved Windows embedded PostgreSQL smoke testing by running under a non-administrator helper user with a prewarmed profile environment. * **Tests** * Added automated packaging/release workflow verification steps (Linux and Windows) to catch embedded PostgreSQL content regressions earlier, including during artifact build and release verification. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
8
.github/workflows/desktop-packaging.yml
vendored
8
.github/workflows/desktop-packaging.yml
vendored
@@ -84,3 +84,11 @@ jobs:
|
||||
- name: Validate packageable closure (electron-builder --dir)
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --dir --publish never
|
||||
|
||||
# FNXC:DesktopEmbeddedPostgres 2026-07-15-10:45:
|
||||
# electron-builder --dir leaves linux-*-unpacked trees; assert embedded Postgres
|
||||
# packaging content (main-bootstrap, natives, omp-runtime) so AppImage regressions
|
||||
# fail on the advisory packaging PR lane, not only at release.
|
||||
- name: Verify Linux AppImage embedded Postgres packaging
|
||||
if: steps.changes.outputs.relevant == 'true'
|
||||
run: node scripts/verify-desktop-linux-pg-packaging.mjs
|
||||
78
.github/workflows/desktop-windows.yml
vendored
78
.github/workflows/desktop-windows.yml
vendored
@@ -22,11 +22,87 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# FNXC:WindowsDesktopPackaging 2026-07-15-00:55:
|
||||
# The embedded-PG smoke boots postgres under a non-admin helper user
|
||||
# (fusion-pg). The FIRST Start-Process -Credential for that user loads its
|
||||
# Windows profile hive (~10-20s), which would blow a test's 15s budget.
|
||||
# Create the user and warm its profile once here, outside any test window;
|
||||
# the launcher resets the user's password before each run, but the warmed
|
||||
# profile persists, so every later launch is ~0.5s.
|
||||
- name: Prewarm embedded-PG helper user profile
|
||||
shell: pwsh
|
||||
run: |
|
||||
$user = "fusion-pg"
|
||||
# Throwaway password for the ephemeral helper user (the launcher resets
|
||||
# it before each run); generated at runtime to avoid a hardcoded literal.
|
||||
$pass = "Fx9!" + ([guid]::NewGuid().ToString("N")) + "#kP"
|
||||
net user $user $pass /add /y 2>&1 | Out-Null
|
||||
$sec = ConvertTo-SecureString $pass -AsPlainText -Force
|
||||
$cred = New-Object System.Management.Automation.PSCredential("$env:COMPUTERNAME\$user", $sec)
|
||||
[void](Start-Process -FilePath cmd.exe -ArgumentList '/c','exit' -Credential $cred -Wait -WindowStyle Hidden)
|
||||
Write-Host "prewarmed $user profile"
|
||||
|
||||
# FNXC:DesktopEmbeddedPostgres 2026-07-14-09:39:
|
||||
# The manual Windows installer path must boot the same embedded database
|
||||
# payload used by Local mode before it can publish an installer artifact.
|
||||
# FNXC:WindowsDesktopPackaging 2026-07-15-02:40:
|
||||
# The runner executes jobs elevated, and PostgreSQL refuses an elevated
|
||||
# (admin) token. Run the WHOLE smoke AS the non-admin helper user
|
||||
# (fusion-pg): the test process, its tmpdir() data dirs, AND postgres all
|
||||
# run as fusion-pg, so postgres inherits a non-admin token and boots via
|
||||
# the normal embedded-postgres path — no in-launcher Start-Process
|
||||
# -Credential / staging / process-kill races.
|
||||
- name: Smoke embedded Postgres on Windows
|
||||
run: pnpm --filter @fusion/core test:embedded-postgres
|
||||
shell: pwsh
|
||||
run: |
|
||||
$user = "fusion-pg"
|
||||
$pass = "Fx9!" + ([guid]::NewGuid().ToString("N")) + "#kP"
|
||||
net user $user $pass /y 2>&1 | Out-Null
|
||||
# FNXC:WindowsDesktopPackaging 2026-07-15-11:25:
|
||||
# Full recursive grants on the workspace + pnpm store (proven green on
|
||||
# win-pg-diag). Narrow grants miss pnpm resolution targets and exit 1
|
||||
# with no useful signal. Capture the bat log so failures surface.
|
||||
Write-Host "granting ACL (workspace + tooling) for $user..."
|
||||
icacls $env:GITHUB_WORKSPACE /grant "*S-1-5-32-545:(OI)(CI)M" /T /C 2>&1 | Out-Null
|
||||
if (Test-Path D:\.pnpm-store) {
|
||||
icacls D:\.pnpm-store /grant "*S-1-5-32-545:(OI)(CI)RX" /T /C 2>&1 | Out-Null
|
||||
}
|
||||
icacls C:\Users\runneradmin /grant "*S-1-5-32-545:RX" /C 2>&1 | Out-Null
|
||||
if (Test-Path C:\Users\runneradmin\setup-pnpm) {
|
||||
icacls C:\Users\runneradmin\setup-pnpm /grant "*S-1-5-32-545:(OI)(CI)RX" /T /C 2>&1 | Out-Null
|
||||
}
|
||||
$nodeDir = Split-Path (Get-Command node).Source -Parent
|
||||
icacls $nodeDir /grant "*S-1-5-32-545:(OI)(CI)RX" /T /C 2>&1 | Out-Null
|
||||
# Traversable HOME/TEMP for the helper user (its tmpdir() lands here).
|
||||
$h = "C:\fusionpg-home"
|
||||
New-Item -ItemType Directory -Force -Path "$h\tmp" | Out-Null
|
||||
icacls $h /grant "*S-1-5-32-545:(OI)(CI)F" /T /C 2>&1 | Out-Null
|
||||
$pnpmDir = Split-Path (Get-Command pnpm).Source -Parent
|
||||
$bat = Join-Path $h "smoke.bat"
|
||||
$log = Join-Path $h "smoke.log"
|
||||
Set-Content -Path $bat -Encoding ASCII -Value @(
|
||||
"@echo off",
|
||||
"set `"USERPROFILE=$h`"",
|
||||
"set `"APPDATA=$h\AppData\Roaming`"",
|
||||
"set `"LOCALAPPDATA=$h\AppData\Local`"",
|
||||
"set `"TEMP=$h\tmp`"",
|
||||
"set `"TMP=$h\tmp`"",
|
||||
"set `"PATH=$nodeDir;$pnpmDir;%PATH%`"",
|
||||
"cd /d $env:GITHUB_WORKSPACE",
|
||||
"call pnpm --filter @fusion/core test:embedded-postgres > `"$log`" 2>&1",
|
||||
"exit /b %ERRORLEVEL%"
|
||||
)
|
||||
Write-Host "running embedded-PG smoke as $user..."
|
||||
$sec = ConvertTo-SecureString $pass -AsPlainText -Force
|
||||
$cred = New-Object System.Management.Automation.PSCredential("$env:COMPUTERNAME\$user", $sec)
|
||||
$p = Start-Process -FilePath "cmd.exe" -ArgumentList '/c',$bat -Credential $cred -Wait -PassThru -WindowStyle Hidden
|
||||
if (Test-Path $log) {
|
||||
Write-Host "----- smoke.log (tail) -----"
|
||||
Get-Content $log -Tail 200
|
||||
} else {
|
||||
Write-Host "smoke.log missing (bat may not have started)"
|
||||
}
|
||||
if ($p.ExitCode -ne 0) { Write-Error "embedded-PG smoke failed (exit $($p.ExitCode))"; exit 1 }
|
||||
|
||||
# FNXC:WindowsDesktopPackaging 2026-07-01-19:45:
|
||||
# Mirror release.yml: build every workspace package's tsc dist (incl.
|
||||
|
||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -333,6 +333,13 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# FNXC:DesktopEmbeddedPostgres 2026-07-15-00:20:
|
||||
# AppImage filename presence is not enough — v0.60.0 shipped without
|
||||
# embedded-postgres / main-bootstrap / omp-runtime. Inspect the linux-*-unpacked
|
||||
# trees electron-builder leaves beside the AppImage.
|
||||
- name: Verify Linux AppImage embedded Postgres packaging
|
||||
run: node scripts/verify-desktop-linux-pg-packaging.mjs
|
||||
|
||||
- name: Sign Linux desktop artifacts
|
||||
if: ${{ env.LINUX_GPG_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
|
||||
7
.github/workflows/test-release.yml
vendored
7
.github/workflows/test-release.yml
vendored
@@ -315,6 +315,13 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# FNXC:DesktopEmbeddedPostgres 2026-07-15-00:20:
|
||||
# AppImage filename presence is not enough — v0.60.0 shipped without
|
||||
# embedded-postgres / main-bootstrap / omp-runtime. Inspect the linux-*-unpacked
|
||||
# trees electron-builder leaves beside the AppImage.
|
||||
- name: Verify Linux AppImage embedded Postgres packaging
|
||||
run: node scripts/verify-desktop-linux-pg-packaging.mjs
|
||||
|
||||
- name: Sign Linux desktop artifacts
|
||||
if: ${{ env.LINUX_GPG_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
|
||||
@@ -59,10 +59,59 @@ describe("desktop release workflow wiring", () => {
|
||||
}
|
||||
expect(manualWindows).toContain("Smoke embedded Postgres on Windows");
|
||||
expect(manualWindows).toContain("pnpm --filter @fusion/core test:embedded-postgres");
|
||||
// FNXC:WindowsDesktopPackaging 2026-07-15-10:45:
|
||||
// windows-latest jobs are elevated; postgres refuses an admin token. CI must
|
||||
// run the smoke as a non-admin helper (fusion-pg) so the process token is
|
||||
// medium integrity and the normal embedded-postgres path works.
|
||||
expect(manualWindows).toContain("fusion-pg");
|
||||
expect(manualWindows).toContain("Start-Process");
|
||||
expect(manualWindows).toContain("Prewarm embedded-PG helper user profile");
|
||||
// FNXC:WindowsDesktopPackaging 2026-07-15-11:45:
|
||||
// GitHub-hosted runners provide GITHUB_WORKSPACE; never couple the helper
|
||||
// user's ACLs or batch working directory to Fusion's current path on D:.
|
||||
expect(manualWindows).toContain("icacls $env:GITHUB_WORKSPACE");
|
||||
expect(manualWindows).toContain('"cd /d $env:GITHUB_WORKSPACE"');
|
||||
expect(manualWindows).not.toContain("D:\\a\\Fusion\\Fusion");
|
||||
expect(advisoryPackaging).toContain("Smoke embedded Postgres lifecycle");
|
||||
expect(advisoryPackaging).toContain("pnpm --filter @fusion/core test:embedded-postgres");
|
||||
});
|
||||
|
||||
it("inspects Linux AppImage unpacked trees for embedded Postgres packaging", async () => {
|
||||
/*
|
||||
* FNXC:DesktopEmbeddedPostgres 2026-07-15-00:20:
|
||||
* v0.60.0 AppImages existed but omitted embedded-postgres, main-bootstrap, and
|
||||
* omp-runtime. Release + test-release must run the packaging content verifier
|
||||
* after electron-builder produces linux-*-unpacked dirs.
|
||||
*/
|
||||
const release = await readRepoFile(".github/workflows/release.yml");
|
||||
const testRelease = await readRepoFile(".github/workflows/test-release.yml");
|
||||
const verifier = await readRepoFile("scripts/verify-desktop-linux-pg-packaging.mjs");
|
||||
|
||||
expect(verifier).toContain("main-bootstrap.cjs");
|
||||
expect(verifier).toContain("embedded-postgres");
|
||||
expect(verifier).toContain("omp-runtime");
|
||||
expect(verifier).toContain("@embedded-postgres");
|
||||
// FNXC:DesktopEmbeddedPostgres 2026-07-15-00:30: Greptile P1 locks —
|
||||
// soname links (pg-symlinks) and runnable omp dist entrypoints, not just
|
||||
// binary names / package-name substrings.
|
||||
expect(verifier).toContain("pg-symlinks.json");
|
||||
expect(verifier).toContain("assertPlatformSonameLinks");
|
||||
expect(verifier).toContain("omp-runtime/dist/index.js");
|
||||
expect(verifier).toContain("omp-runtime/dist/probe.js");
|
||||
// FNXC:DesktopEmbeddedPostgres 2026-07-15-13:20: The x64 unpacked tree can
|
||||
// carry optional arm64 packages too; retain validation of x64 binaries and
|
||||
// SONAME links without treating that multi-arch closure as a packaging error.
|
||||
expect(verifier).toContain("platforms.includes(expectedPlatform)");
|
||||
expect(verifier).toContain("expectedPlatform, \"native\", \"bin\"");
|
||||
expect(verifier).not.toContain("found ${platform}; expected ${expectedPlatform}");
|
||||
|
||||
const advisoryPackaging = await readRepoFile(".github/workflows/desktop-packaging.yml");
|
||||
for (const workflow of [release, testRelease, advisoryPackaging]) {
|
||||
expect(workflow).toContain("Verify Linux AppImage embedded Postgres packaging");
|
||||
expect(workflow).toContain("node scripts/verify-desktop-linux-pg-packaging.mjs");
|
||||
}
|
||||
});
|
||||
|
||||
it("wires release aggregation to include desktop assets across platforms", async () => {
|
||||
const release = await readRepoFile(".github/workflows/release.yml");
|
||||
|
||||
|
||||
393
scripts/verify-desktop-linux-pg-packaging.mjs
Normal file
393
scripts/verify-desktop-linux-pg-packaging.mjs
Normal file
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* FNXC:DesktopEmbeddedPostgres 2026-07-15-00:20:
|
||||
* Linux AppImage release verification for embedded Postgres packaging.
|
||||
* v0.60.0 shipped without embedded-postgres / main-bootstrap / omp-runtime inside the
|
||||
* AppImage, so Local mode could never boot on Linux. Existence of the .AppImage file
|
||||
* alone is insufficient — assert the packaged Electron tree after electron-builder.
|
||||
*
|
||||
* Prefer inspecting electron-builder's linux-*-unpacked directories (no squashfs tools
|
||||
* required). Optionally also list AppImage contents when unsquashfs is available.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-desktop-linux-pg-packaging.mjs
|
||||
* node scripts/verify-desktop-linux-pg-packaging.mjs --dist packages/desktop/dist-electron
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// FNXC:DesktopEmbeddedPostgres 2026-07-15-10:45:
|
||||
// Use fileURLToPath(import.meta.url) + path.dirname — eslint no-undef rejects
|
||||
// bare `URL` in .mjs (not in env globals for this script).
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..");
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/*
|
||||
* FNXC:DesktopEmbeddedPostgres 2026-07-15-00:30:
|
||||
* Greptile P1: omp-runtime can appear as package metadata without a built dist.
|
||||
* Dashboard Local mode imports the package's import condition, which resolves to
|
||||
* dist/index.js (and probe.js for runtime probes). Require those entrypoints.
|
||||
*/
|
||||
const OMP_RUNTIME_ASAR_ENTRYPOINTS = [
|
||||
"/node_modules/@fusion-plugin-examples/omp-runtime/dist/index.js",
|
||||
"/node_modules/@fusion-plugin-examples/omp-runtime/dist/probe.js",
|
||||
];
|
||||
|
||||
function parseArgs(argv) {
|
||||
let distDir = join(repoRoot, "packages", "desktop", "dist-electron");
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === "--dist" && argv[i + 1]) {
|
||||
distDir = resolve(argv[++i]);
|
||||
}
|
||||
}
|
||||
return { distDir };
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[verify-desktop-linux-pg] ${message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function ok(message) {
|
||||
console.log(`[verify-desktop-linux-pg] ${message}`);
|
||||
}
|
||||
|
||||
function findAsar() {
|
||||
// @electron/asar is a transitive dep of electron-builder. Under pnpm it is
|
||||
// often not importable via createRequire from the monorepo root, so fall back
|
||||
// to scanning the virtual store after the normal resolve paths.
|
||||
const resolvePaths = [
|
||||
join(repoRoot, "packages", "desktop"),
|
||||
repoRoot,
|
||||
process.cwd(),
|
||||
];
|
||||
for (const base of resolvePaths) {
|
||||
try {
|
||||
return require.resolve("@electron/asar/bin/asar.js", { paths: [base] });
|
||||
} catch {
|
||||
// try next base
|
||||
}
|
||||
}
|
||||
try {
|
||||
return require.resolve("@electron/asar/bin/asar.js");
|
||||
} catch {
|
||||
// continue to pnpm store scan
|
||||
}
|
||||
|
||||
for (const storeRoot of [
|
||||
join(repoRoot, "node_modules", ".pnpm"),
|
||||
join(repoRoot, "packages", "desktop", "node_modules", ".pnpm"),
|
||||
]) {
|
||||
if (!existsSync(storeRoot)) continue;
|
||||
for (const entry of readdirSync(storeRoot)) {
|
||||
if (!entry.startsWith("@electron+asar@")) continue;
|
||||
const candidate = join(
|
||||
storeRoot,
|
||||
entry,
|
||||
"node_modules",
|
||||
"@electron",
|
||||
"asar",
|
||||
"bin",
|
||||
"asar.js",
|
||||
);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* electron-builder writes one unpacked directory per arch under dist-electron.
|
||||
* Accept both historical and current naming patterns.
|
||||
*/
|
||||
function discoverUnpackedDirs(distDir) {
|
||||
if (!existsSync(distDir)) return [];
|
||||
return readdirSync(distDir)
|
||||
.filter((name) => {
|
||||
if (!name.includes("unpacked")) return false;
|
||||
const full = join(distDir, name);
|
||||
try {
|
||||
return statSync(full).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map((name) => join(distDir, name))
|
||||
.filter((full) => {
|
||||
// Linux targets only (skip mac-unpacked / win-unpacked when mixed).
|
||||
const base = full.split(/[/\\]/).pop() ?? "";
|
||||
return base.startsWith("linux") || base.includes("linux");
|
||||
});
|
||||
}
|
||||
|
||||
function resourcesRoot(unpackedDir) {
|
||||
// electron-builder linux layout: <unpacked>/resources/app.asar
|
||||
const direct = join(unpackedDir, "resources");
|
||||
if (existsSync(join(direct, "app.asar"))) return direct;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:DesktopEmbeddedPostgres 2026-07-15-00:30:
|
||||
* Greptile P1: postgres/initdb can be present while postinstall soname links from
|
||||
* hydrate-symlinks.js (pg-symlinks.json) are missing. Linux then fails at runtime
|
||||
* with missing libicui18n.so.60 (etc.). Assert every recorded target path exists
|
||||
* (symlink or regular file) under the platform native root.
|
||||
*/
|
||||
function assertPlatformSonameLinks(unpackedDir, platformRoot, platform) {
|
||||
const nativeRoot = join(platformRoot, platform, "native");
|
||||
const markerPath = join(nativeRoot, "pg-symlinks.json");
|
||||
if (!existsSync(markerPath)) {
|
||||
fail(`${unpackedDir}: ${platform} missing native/pg-symlinks.json (cannot verify soname links)`);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = JSON.parse(readFileSync(markerPath, "utf8"));
|
||||
} catch (err) {
|
||||
fail(
|
||||
`${unpackedDir}: ${platform} native/pg-symlinks.json is not valid JSON: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
fail(`${unpackedDir}: ${platform} native/pg-symlinks.json has no link entries`);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageRoot = join(platformRoot, platform);
|
||||
let missing = 0;
|
||||
for (const entry of entries) {
|
||||
// FNXC:DesktopEmbeddedPostgres 2026-07-15-11:45:
|
||||
// Review feedback requires malformed link manifests to fail closed. Skipping
|
||||
// them could let an empty or producer-corrupted manifest claim SONAME success.
|
||||
if (!entry || typeof entry !== "object") {
|
||||
fail(`${unpackedDir}: ${platform} has an invalid pg-symlinks entry`);
|
||||
missing += 1;
|
||||
continue;
|
||||
}
|
||||
const sourceRel = typeof entry.source === "string" ? entry.source : "";
|
||||
const targetRel = typeof entry.target === "string" ? entry.target : "";
|
||||
if (!sourceRel || !targetRel) {
|
||||
fail(`${unpackedDir}: ${platform} has a pg-symlinks entry without source/target`);
|
||||
missing += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paths in pg-symlinks.json are relative to the platform package root
|
||||
// (e.g. "native/lib/libicui18n.so.60.2" -> "native/lib/libicui18n.so.60").
|
||||
const sourcePath = join(packageRoot, sourceRel);
|
||||
const targetPath = join(packageRoot, targetRel);
|
||||
|
||||
if (!pathExists(sourcePath)) {
|
||||
fail(`${unpackedDir}: ${platform} missing soname source ${sourceRel}`);
|
||||
missing += 1;
|
||||
continue;
|
||||
}
|
||||
if (!pathExists(targetPath)) {
|
||||
fail(
|
||||
`${unpackedDir}: ${platform} missing soname link target ${targetRel} ` +
|
||||
`(hydrate-symlinks postinstall did not materialize ABI names; ` +
|
||||
`embedded Postgres will fail with shared-library load errors)`,
|
||||
);
|
||||
missing += 1;
|
||||
}
|
||||
}
|
||||
if (missing === 0) {
|
||||
ok(`${unpackedDir}: ${platform} soname links present (${entries.length} pg-symlinks entries)`);
|
||||
}
|
||||
}
|
||||
|
||||
/** True if path exists as a real file, directory, or non-dangling symlink. */
|
||||
function pathExists(p) {
|
||||
try {
|
||||
const st = lstatSync(p);
|
||||
if (st.isSymbolicLink()) {
|
||||
// Dangling links must fail — existsSync follows and returns false for those.
|
||||
return existsSync(p);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function listIncludesAsarPath(list, entry) {
|
||||
// FNXC:DesktopEmbeddedPostgres 2026-07-15-11:45:
|
||||
// ASAR listings may omit a leading slash. Normalize line entries before exact
|
||||
// matching so a source map (for example index.js.map) cannot satisfy a runtime
|
||||
// entrypoint requirement through a substring match.
|
||||
const normalizedEntries = new Set(
|
||||
list
|
||||
.split(/\r?\n/)
|
||||
.map((path) => `/${path.trim().replace(/^\/+/, "")}`)
|
||||
.filter((path) => path !== "/"),
|
||||
);
|
||||
return normalizedEntries.has(`/${entry.replace(/^\/+/, "")}`);
|
||||
}
|
||||
|
||||
function expectedLinuxPlatform(unpackedDir) {
|
||||
const name = unpackedDir.split(/[/\\]/).pop() ?? "";
|
||||
// electron-builder calls x64's default output linux-unpacked and appends
|
||||
// non-default target architectures such as linux-arm64-unpacked.
|
||||
return name.includes("arm64") ? "linux-arm64" : "linux-x64";
|
||||
}
|
||||
|
||||
function isRegularExecutable(path) {
|
||||
try {
|
||||
const stat = statSync(path);
|
||||
return stat.isFile() && (stat.mode & 0o111) !== 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertUnpackedTree(unpackedDir, asarBin) {
|
||||
const resources = resourcesRoot(unpackedDir);
|
||||
if (!resources) {
|
||||
fail(`${unpackedDir}: missing resources/app.asar`);
|
||||
return;
|
||||
}
|
||||
|
||||
const asarPath = join(resources, "app.asar");
|
||||
const unpackedNm = join(resources, "app.asar.unpacked", "node_modules");
|
||||
const embeddedRoot = join(unpackedNm, "embedded-postgres");
|
||||
const platformRoot = join(unpackedNm, "@embedded-postgres");
|
||||
|
||||
if (!existsSync(embeddedRoot)) {
|
||||
fail(`${unpackedDir}: app.asar.unpacked is missing embedded-postgres (asarUnpack/files allowlist broken)`);
|
||||
} else {
|
||||
ok(`${unpackedDir}: embedded-postgres present under app.asar.unpacked`);
|
||||
}
|
||||
|
||||
if (!existsSync(platformRoot)) {
|
||||
fail(`${unpackedDir}: app.asar.unpacked is missing @embedded-postgres/*`);
|
||||
} else {
|
||||
const platforms = readdirSync(platformRoot).filter((n) => n.startsWith("linux-"));
|
||||
const expectedPlatform = expectedLinuxPlatform(unpackedDir);
|
||||
if (platforms.length === 0) {
|
||||
fail(`${unpackedDir}: no @embedded-postgres/linux-* packages in asar.unpacked (got: ${readdirSync(platformRoot).join(", ") || "none"})`);
|
||||
} else {
|
||||
ok(`${unpackedDir}: platform packages: ${platforms.join(", ")}`);
|
||||
// FNXC:DesktopEmbeddedPostgres 2026-07-15-13:20:
|
||||
// electron-builder can retain optional native packages for several Linux
|
||||
// CPUs in one unpacked tree. Validate the target CPU's runnable payload,
|
||||
// rather than rejecting extra architecture packages that do not affect it.
|
||||
if (!platforms.includes(expectedPlatform)) {
|
||||
fail(`${unpackedDir}: missing @embedded-postgres/${expectedPlatform}`);
|
||||
} else {
|
||||
for (const bin of ["initdb", "pg_ctl", "postgres"]) {
|
||||
const binPath = join(platformRoot, expectedPlatform, "native", "bin", bin);
|
||||
if (!isRegularExecutable(binPath)) {
|
||||
fail(`${unpackedDir}: missing executable file ${expectedPlatform}/native/bin/${bin}`);
|
||||
}
|
||||
}
|
||||
assertPlatformSonameLinks(unpackedDir, platformRoot, expectedPlatform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// package.json main must be the CJS bootstrap that patches spawn before ESM main.
|
||||
if (!asarBin) {
|
||||
fail("Could not resolve @electron/asar; cannot read packaged package.json main");
|
||||
return;
|
||||
}
|
||||
const listed = spawnSync(process.execPath, [asarBin, "list", asarPath], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
if (listed.status !== 0) {
|
||||
fail(`asar list failed for ${asarPath}: ${listed.stderr || listed.stdout}`);
|
||||
return;
|
||||
}
|
||||
const list = listed.stdout;
|
||||
if (!listIncludesAsarPath(list, "/dist/main-bootstrap.cjs")) {
|
||||
fail(`${unpackedDir}: app.asar is missing dist/main-bootstrap.cjs`);
|
||||
} else {
|
||||
ok(`${unpackedDir}: main-bootstrap.cjs present in app.asar`);
|
||||
}
|
||||
|
||||
// Require the runnable dist entrypoints, not a bare "omp-runtime" substring.
|
||||
const missingOmp = OMP_RUNTIME_ASAR_ENTRYPOINTS.filter((entry) => !listIncludesAsarPath(list, entry));
|
||||
if (missingOmp.length > 0) {
|
||||
fail(
|
||||
`${unpackedDir}: app.asar missing omp-runtime dist entrypoint(s): ${missingOmp.join(", ")} ` +
|
||||
`(package metadata alone is insufficient; Local mode fails with ERR_MODULE_NOT_FOUND after PG boot)`,
|
||||
);
|
||||
} else {
|
||||
ok(`${unpackedDir}: omp-runtime dist entrypoints present in app.asar`);
|
||||
}
|
||||
|
||||
// extract-file writes package.json into cwd — use a private temp dir under resources.
|
||||
const extractCwd = join(resources, ".fusion-pg-verify-tmp");
|
||||
try {
|
||||
rmSync(extractCwd, { recursive: true, force: true });
|
||||
mkdirSync(extractCwd, { recursive: true });
|
||||
const extracted = spawnSync(
|
||||
process.execPath,
|
||||
[asarBin, "extract-file", asarPath, "package.json"],
|
||||
{ encoding: "utf8", cwd: extractCwd },
|
||||
);
|
||||
const pkgPath = join(extractCwd, "package.json");
|
||||
if (extracted.status !== 0 || !existsSync(pkgPath)) {
|
||||
fail(
|
||||
`${unpackedDir}: failed to extract package.json from app.asar (${extracted.stderr || extracted.stdout})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
||||
if (pkg.main !== "dist/main-bootstrap.cjs") {
|
||||
fail(
|
||||
`${unpackedDir}: package.json main is ${JSON.stringify(pkg.main)}; expected "dist/main-bootstrap.cjs"`,
|
||||
);
|
||||
} else {
|
||||
ok(`${unpackedDir}: package.json main is dist/main-bootstrap.cjs`);
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`${unpackedDir}: package.json main check error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
rmSync(extractCwd, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { distDir } = parseArgs(process.argv.slice(2));
|
||||
ok(`inspecting ${distDir}`);
|
||||
|
||||
const unpacked = discoverUnpackedDirs(distDir);
|
||||
if (unpacked.length === 0) {
|
||||
fail(
|
||||
`No linux-*-unpacked directories under ${distDir}. ` +
|
||||
"Run electron-builder --linux first (or pass --dist to the packaging output).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const asarBin = findAsar();
|
||||
for (const dir of unpacked) {
|
||||
assertUnpackedTree(dir, asarBin);
|
||||
}
|
||||
|
||||
if (process.exitCode && process.exitCode !== 0) {
|
||||
fail("Linux desktop embedded Postgres packaging verification FAILED");
|
||||
return;
|
||||
}
|
||||
ok("Linux desktop embedded Postgres packaging verification passed");
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user