## 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 -->
415 lines
15 KiB
JavaScript
415 lines
15 KiB
JavaScript
#!/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.
|
|
*/
|
|
export 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;
|
|
}
|
|
}
|
|
|
|
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
|
|
// 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(/^\/+/, "")}`);
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
export function isRegularExecutable(path) {
|
|
try {
|
|
const stat = statSync(path);
|
|
return stat.isFile() && (stat.mode & 0o111) !== 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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}`);
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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");
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
main();
|
|
}
|