fix(desktop): assert Linux AppImage embeds Postgres packaging (#2131)
## Summary Verification of the Fusion Linux AppImage + embedded Postgres packaging surface (follow-on to #2106 Mac packaging and #2117 Windows PG work). ### What we found 1. **Published `v0.60.0` AppImages are broken for Local/embedded Postgres** (pre-#2106): - No `embedded-postgres` / `@embedded-postgres/*` in `app.asar` or `app.asar.unpacked` - `package.json` `main` is still `dist/main.js` (no `main-bootstrap.cjs`) - No `omp-runtime` packaged - `asar.unpacked` only has incidental natives (pi-tui, esbuild, node-pty) 2. **Current main (post-#2106) packaging config is correct** (verified via mac `--dir` pack on this host): - `main` → `dist/main-bootstrap.cjs` - Full `asarUnpack` of `embedded-postgres` + `@embedded-postgres/**` - Native bins present under `app.asar.unpacked` - `omp-runtime` dist present in asar 3. **Linux arm64 native PG binary smoke** (`@embedded-postgres/linux-arm64` 15.18) in Docker: initdb → start → create DB → persist across restart → **OK** (requires postinstall soname symlinks from `hydrate-symlinks.js` / `pg-symlinks.json`). 4. **Host blocker:** this machine is macOS arm64 — cannot produce or execute a Linux AppImage end-to-end. Linux packaging must run on `ubuntu-latest` CI. ### Fix in this PR Release jobs only checked that `*.AppImage` files existed — which is how v0.60.0 shipped empty of Postgres. Add: - `scripts/verify-desktop-linux-pg-packaging.mjs` — inspects `linux-*-unpacked` trees for: - `app.asar.unpacked` embedded-postgres + `@embedded-postgres/linux-*` bins - `dist/main-bootstrap.cjs` + `package.json` main - `omp-runtime` presence - Wire into `release.yml` + `test-release.yml` after AppImage artifact checks - Unit test lock in `release-workflow.test.ts` ## Test plan - [x] `pnpm --filter @fusion/core test:embedded-postgres` (33/33 with 60s timeout; default 15s flaked under load) - [x] Desktop packaging unit tests (`electron-builder-config`, `build-bundling`, `release-workflow`) - [x] Inspected published `Fusion-0.60.0-linux-arm64.AppImage` (checksum OK; PG packaging absent) - [x] Post-#2106 `electron-builder --mac --dir`: asar.unpacked has PG + bootstrap + omp - [x] Docker linux-arm64 native binary lifecycle smoke - [ ] CI `build-desktop-linux` on this PR (runs the new verifier against real linux-unpacked) ## Gaps remaining (not fixed here) | Gap | Notes | |-----|-------| | Full AppImage launch + `/api/health` on Linux | Needs Linux host/CI with display or headless Electron | | No post-#2106 published AppImage yet | Next release will include packaging fixes; this PR stops empty AppImages | | README still says `linux-x64.AppImage` | Actual name is `linux-x86_64.AppImage` (workflow already correct) | | Windows packaged Local | Tracked by #2117 / verify-desktop | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Added verification for Linux AppImage packaging to ensure embedded Postgres binaries, platform files, symlinks, and runtime assets are included correctly. * Confirmed packaged application metadata points to the expected startup entry point. * Improved detection of invalid, missing, or incorrectly formatted packaging artifacts. * **Tests** * Added coverage for architecture-specific binaries, exact ASAR paths, executable files, and symlink metadata. * Verified packaging checks run after Linux desktop artifacts are created. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const verifierPath = path.resolve(
|
||||
import.meta.dirname,
|
||||
"../../../../scripts/verify-desktop-linux-pg-packaging.mjs",
|
||||
);
|
||||
const verifier = await import(verifierPath);
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("Linux AppImage Postgres packaging verifier", () => {
|
||||
it("matches normalized ASAR paths exactly", () => {
|
||||
// FNXC:DesktopEmbeddedPostgres 2026-07-15-11:55:
|
||||
// Runtime entrypoints must be actual ASAR files, not source maps whose
|
||||
// names merely contain the required path.
|
||||
const listing = "/dist/main-bootstrap.cjs.map\nnode_modules/pkg/dist/index.js.map\n/dist/main-bootstrap.cjs\n";
|
||||
|
||||
expect(verifier.listIncludesAsarPath(listing, "/dist/main-bootstrap.cjs")).toBe(true);
|
||||
expect(verifier.listIncludesAsarPath(listing, "/node_modules/pkg/dist/index.js")).toBe(false);
|
||||
});
|
||||
|
||||
it("derives the matching platform package and requires executable regular files", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "fusion-pg-verifier-"));
|
||||
tempDirs.push(tempDir);
|
||||
const executable = path.join(tempDir, "postgres");
|
||||
const notExecutable = path.join(tempDir, "initdb");
|
||||
const x64Elf = path.join(tempDir, "x64-elf");
|
||||
const arm64Elf = path.join(tempDir, "arm64-elf");
|
||||
await writeFile(executable, "#!/bin/sh\n");
|
||||
await writeFile(notExecutable, "#!/bin/sh\n");
|
||||
await chmod(executable, 0o755);
|
||||
await chmod(notExecutable, 0o644);
|
||||
const elfHeader = (machine: number) => {
|
||||
const header = Buffer.alloc(20);
|
||||
header.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]);
|
||||
header.writeUInt16LE(machine, 18);
|
||||
return header;
|
||||
};
|
||||
await writeFile(x64Elf, elfHeader(62));
|
||||
await writeFile(arm64Elf, elfHeader(183));
|
||||
|
||||
expect(verifier.expectedLinuxPlatform("/tmp/linux-unpacked")).toBe("linux-x64");
|
||||
expect(verifier.expectedLinuxPlatform("/tmp/linux-arm64-unpacked")).toBe("linux-arm64");
|
||||
expect(verifier.isRegularExecutable(executable)).toBe(true);
|
||||
expect(verifier.isRegularExecutable(notExecutable)).toBe(false);
|
||||
expect(verifier.isRegularExecutable(tempDir)).toBe(false);
|
||||
expect(verifier.hasExpectedElfArchitecture(x64Elf, "linux-x64")).toBe(true);
|
||||
expect(verifier.hasExpectedElfArchitecture(x64Elf, "linux-arm64")).toBe(false);
|
||||
expect(verifier.hasExpectedElfArchitecture(arm64Elf, "linux-arm64")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts complete SONAME metadata and rejects malformed package fixtures", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "fusion-pg-verifier-"));
|
||||
tempDirs.push(tempDir);
|
||||
const platform = "linux-x64";
|
||||
const nativeRoot = path.join(tempDir, platform, "native");
|
||||
await mkdir(path.join(nativeRoot, "lib"), { recursive: true });
|
||||
await writeFile(path.join(nativeRoot, "lib", "libpq.so.1"), "source");
|
||||
await writeFile(path.join(nativeRoot, "lib", "libpq.so"), "target");
|
||||
await writeFile(
|
||||
path.join(nativeRoot, "pg-symlinks.json"),
|
||||
JSON.stringify([{ source: "native/lib/libpq.so.1", target: "native/lib/libpq.so" }]),
|
||||
);
|
||||
|
||||
// FNXC:DesktopEmbeddedPostgres 2026-07-15-11:55:
|
||||
// Exercise real manifest fixtures: valid links pass while malformed data
|
||||
// must fail the verifier rather than silently skipping a broken payload.
|
||||
process.exitCode = undefined;
|
||||
verifier.assertPlatformSonameLinks("fixture", tempDir, platform);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
|
||||
await writeFile(path.join(nativeRoot, "pg-symlinks.json"), JSON.stringify([{}]));
|
||||
verifier.assertPlatformSonameLinks("fixture", tempDir, platform);
|
||||
expect(process.exitCode).toBe(1);
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
});
|
||||
@@ -109,6 +109,12 @@ describe("desktop release workflow wiring", () => {
|
||||
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");
|
||||
// FNXC:DesktopEmbeddedPostgres 2026-07-15-11:55:
|
||||
// This verifier reads electron-builder's unpacked tree, so invoking it
|
||||
// before the Linux packaging command would only validate stale output.
|
||||
expect(workflow.indexOf("node scripts/verify-desktop-linux-pg-packaging.mjs")).toBeGreaterThan(
|
||||
workflow.indexOf("Package Linux desktop artifacts"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ function resourcesRoot(unpackedDir) {
|
||||
* 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) {
|
||||
export function assertPlatformSonameLinks(unpackedDir, platformRoot, platform) {
|
||||
const nativeRoot = join(platformRoot, platform, "native");
|
||||
const markerPath = join(nativeRoot, "pg-symlinks.json");
|
||||
if (!existsSync(markerPath)) {
|
||||
@@ -227,7 +227,7 @@ function pathExists(p) {
|
||||
}
|
||||
}
|
||||
|
||||
function listIncludesAsarPath(list, entry) {
|
||||
export 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
|
||||
@@ -241,14 +241,14 @@ function listIncludesAsarPath(list, entry) {
|
||||
return normalizedEntries.has(`/${entry.replace(/^\/+/, "")}`);
|
||||
}
|
||||
|
||||
function expectedLinuxPlatform(unpackedDir) {
|
||||
export 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) {
|
||||
export function isRegularExecutable(path) {
|
||||
try {
|
||||
const stat = statSync(path);
|
||||
return stat.isFile() && (stat.mode & 0o111) !== 0;
|
||||
@@ -257,6 +257,20 @@ function isRegularExecutable(path) {
|
||||
}
|
||||
}
|
||||
|
||||
export function hasExpectedElfArchitecture(path, platform) {
|
||||
try {
|
||||
const header = readFileSync(path);
|
||||
if (header.length < 20 || header[0] !== 0x7f || header.toString("ascii", 1, 4) !== "ELF") {
|
||||
return false;
|
||||
}
|
||||
const littleEndian = header[5] === 1;
|
||||
const machine = littleEndian ? header.readUInt16LE(18) : header.readUInt16BE(18);
|
||||
return (platform === "linux-x64" && machine === 62) || (platform === "linux-arm64" && machine === 183);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function assertUnpackedTree(unpackedDir, asarBin) {
|
||||
const resources = resourcesRoot(unpackedDir);
|
||||
if (!resources) {
|
||||
@@ -295,6 +309,11 @@ function assertUnpackedTree(unpackedDir, asarBin) {
|
||||
const binPath = join(platformRoot, expectedPlatform, "native", "bin", bin);
|
||||
if (!isRegularExecutable(binPath)) {
|
||||
fail(`${unpackedDir}: missing executable file ${expectedPlatform}/native/bin/${bin}`);
|
||||
// FNXC:DesktopEmbeddedPostgres 2026-07-15-12:05:
|
||||
// A correctly named package can still contain another CPU's payload.
|
||||
// Verify ELF e_machine for the target platform before release approval.
|
||||
} else if (!hasExpectedElfArchitecture(binPath, expectedPlatform)) {
|
||||
fail(`${unpackedDir}: ${expectedPlatform}/native/bin/${bin} is not a ${expectedPlatform} ELF binary`);
|
||||
}
|
||||
}
|
||||
assertPlatformSonameLinks(unpackedDir, platformRoot, expectedPlatform);
|
||||
@@ -390,4 +409,6 @@ function main() {
|
||||
ok("Linux desktop embedded Postgres packaging verification passed");
|
||||
}
|
||||
|
||||
main();
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user