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:
gsxdsm
2026-07-15 14:41:05 -07:00
committed by GitHub
parent 329fc1f664
commit edac617e10
3 changed files with 115 additions and 5 deletions

View File

@@ -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;
});
});

View File

@@ -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"),
);
}
});

View File

@@ -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();
}