ci(release): harden packaged app builds

This commit is contained in:
NimBold
2026-06-30 20:39:52 +03:30
parent 74275013dc
commit fcb7fc91f6
7 changed files with 342 additions and 60 deletions
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
function run(command, args) {
const executable = process.platform === 'win32' && command === 'npm' ? 'npm.cmd' : command;
const result = spawnSync(executable, args, {
cwd: repoRoot,
stdio: 'inherit',
});
if (result.error) {
console.error(`Failed to run ${executable}: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
run(process.execPath, ['scripts/stage-engines.js']);
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
if (process.env.FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE === '1') {
const engineDist = path.join(repoRoot, 'src-tauri', 'engine-dist');
fs.rmSync(engineDist, { recursive: true, force: true });
fs.mkdirSync(engineDist, { recursive: true });
console.log('Omitted engine-dist from the initial Tauri bundle; release packaging will repack verified engines.');
}
run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build']);
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function fail(message) {
console.error(message);
process.exit(1);
}
function mustBeDirectory(directory, label) {
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
fail(`${label} does not exist or is not a directory: ${directory}`);
}
}
function mustBeFile(file, label) {
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
fail(`${label} does not exist or is not a file: ${file}`);
}
}
function findSingleAppImage(directory) {
mustBeDirectory(directory, 'AppImage bundle directory');
const appImages = fs
.readdirSync(directory)
.filter(name => name.endsWith('.AppImage'))
.map(name => path.join(directory, name));
if (appImages.length !== 1) {
fail(`Expected exactly one AppImage in ${directory}, found ${appImages.length}.`);
}
return appImages[0];
}
function validatePayloadManifest(root, target, label) {
const manifestPath = path.join(root, 'payload-manifest.json');
mustBeFile(manifestPath, `${label} payload manifest`);
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch (error) {
fail(`${label} payload manifest is not valid JSON: ${error.message}`);
}
if (manifest.target !== target) {
fail(`${label} payload target mismatch: expected ${target}, got ${manifest.target}`);
}
const expectedFiles = Object.keys(manifest.files || {}).sort();
const actualFiles = collectRegularFiles(root, { ignoredNames: ['payload-manifest.json'] })
.map(file => path.relative(root, file).split(path.sep).join('/'))
.sort();
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
fail(`${label} payload files do not match payload-manifest.json.`);
}
for (const relative of expectedFiles) {
const file = path.join(root, relative);
if (sha256(file) !== manifest.files[relative]) {
fail(`${label} payload checksum mismatch: ${relative}`);
}
}
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? repoRoot,
env: { ...process.env, ...options.env },
stdio: options.stdio ?? 'inherit',
encoding: options.stdio === 'pipe' ? 'utf8' : undefined,
});
if (result.error) {
fail(`Failed to run ${command}: ${result.error.message}`);
}
if (result.status !== 0) {
if (options.stdio === 'pipe') {
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
}
fail(`${command} exited with status ${result.status}`);
}
}
const target = argValue('--target') || process.env.FIRELINK_TARGET_TRIPLE || process.env.TAURI_ENV_TARGET_TRIPLE;
if (!target) {
fail('Pass --target <triple>.');
}
const bundleDirectory = path.join(repoRoot, 'src-tauri', 'target', target, 'release', 'bundle', 'appimage');
const appDir = path.resolve(argValue('--appdir') || path.join(bundleDirectory, 'Firelink.AppDir'));
const appImage = path.resolve(argValue('--appimage') || findSingleAppImage(bundleDirectory));
const appImageTool = path.resolve(argValue('--appimagetool') || process.env.APPIMAGETOOL || 'appimagetool');
const source = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
const destination = path.join(appDir, 'usr', 'lib', 'Firelink', 'engine-dist', target);
mustBeDirectory(appDir, 'Firelink.AppDir');
mustBeDirectory(source, 'Provisioned engine payload');
mustBeFile(appImage, 'AppImage');
mustBeFile(appImageTool, 'appimagetool');
validatePayloadManifest(source, target, 'Provisioned engine');
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.cpSync(source, destination, {
recursive: true,
dereference: false,
preserveTimestamps: true,
});
validatePayloadManifest(destination, target, 'AppDir engine');
fs.chmodSync(appImageTool, 0o755);
run(appImageTool, [appDir, appImage], {
env: {
APPIMAGE_EXTRACT_AND_RUN: '1',
ARCH: 'x86_64',
},
});
const extractRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-appimage-'));
try {
fs.chmodSync(appImage, 0o755);
run(appImage, ['--appimage-extract'], {
cwd: extractRoot,
env: { APPIMAGE_EXTRACT_AND_RUN: '1' },
stdio: 'pipe',
});
const extractedPayload = path.join(extractRoot, 'squashfs-root', 'usr', 'lib', 'Firelink', 'engine-dist', target);
mustBeDirectory(extractedPayload, 'Extracted AppImage engine payload');
validatePayloadManifest(extractedPayload, target, 'Extracted AppImage engine');
} finally {
fs.rmSync(extractRoot, { recursive: true, force: true });
}
console.log(`Repacked and verified AppImage engine payload for ${target}.`);
Regular → Executable
+116 -27
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { execFileSync, spawn } from 'node:child_process';
import path from 'node:path';
function argValue(name) {
@@ -12,52 +12,127 @@ if (!executableArg) {
console.error('Pass --executable <path>.');
process.exit(1);
}
const executable = path.resolve(executableArg);
const executable = path.resolve(executableArg);
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
const child = spawn(executable, [], {
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
detached: process.platform !== 'win32',
env: {
...process.env,
env: {
...process.env,
FIRELINK_SMOKE_TEST: '1',
WEBKIT_DISABLE_COMPOSITING_MODE: '1',
GDK_BACKEND: 'x11'
GDK_BACKEND: 'x11',
},
stdio: ['ignore', 'pipe', 'pipe']
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
let spawnError = null;
let readyPort = null;
child.on('error', error => {
spawnError = error;
});
child.on('exit', (code, signal) => {
if (readyPort === null) {
console.error(`Child exited prematurely with code ${code} signal ${signal}`);
}
});
child.stderr.on('data', data => {
stderr += data.toString();
});
let readyPort = null;
for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) {
for (let port = 6412; port <= 6422; port += 1) {
try {
const response = await fetch(`http://127.0.0.1:${port}/ping`);
if (response.headers.get('x-firelink-server') === '1') {
readyPort = port;
break;
}
} catch {}
}
if (readyPort === null) {
await new Promise(resolve => setTimeout(resolve, 250));
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function findReadyPort() {
for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) {
if (spawnError) {
break;
}
for (let port = 6412; port <= 6422; port += 1) {
try {
const response = await fetch(`http://127.0.0.1:${port}/ping`);
if (response.headers.get('x-firelink-server') === '1') {
readyPort = port;
break;
}
} catch {}
}
if (readyPort === null) {
await sleep(250);
}
}
}
if (process.platform === 'win32') {
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
} else {
function assertNoVisibleWindows(rootPid) {
if (process.platform !== 'win32') {
return;
}
const script = `
$root = ${rootPid}
$all = Get-CimInstance Win32_Process
$pending = @($root)
$descendants = @()
while ($pending.Count -gt 0) {
$parent = $pending[0]
if ($pending.Count -eq 1) {
$pending = @()
} else {
$pending = $pending[1..($pending.Count - 1)]
}
$children = @($all | Where-Object { $_.ParentProcessId -eq $parent })
foreach ($child in $children) {
$descendants += $child.ProcessId
$pending += $child.ProcessId
}
}
$visible = foreach ($pid in $descendants) {
$process = Get-Process -Id $pid -ErrorAction SilentlyContinue
if ($process -and $process.MainWindowHandle -ne 0) {
"$($process.ProcessName)($pid)"
}
}
if ($visible.Count -gt 0) {
Write-Error "Visible child process windows detected: $($visible -join ', ')"
exit 1
}
`;
try {
execFileSync('powershell', ['-NoProfile', '-Command', script], {
stdio: 'pipe',
windowsHide: true,
});
} catch (error) {
const detail = [error.stdout?.toString(), error.stderr?.toString()].filter(Boolean).join('\n').trim();
throw new Error(detail || 'Visible child process window check failed.');
}
}
function terminateChild() {
if (!child.pid) {
return;
}
if (process.platform === 'win32') {
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
return;
}
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
@@ -65,9 +140,23 @@ if (process.platform === 'win32') {
}
}
if (readyPort === null) {
const detail = spawnError?.message || stderr.slice(-1000);
console.error(`Packaged Firelink did not expose extension server. ${detail}`);
process.exit(1);
await findReadyPort();
try {
if (readyPort === null) {
if (spawnError) {
console.error(`Packaged Firelink failed to start: ${spawnError.message}`);
} else {
console.error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
}
process.exit(1);
}
if (assertNoVisibleChildWindows) {
assertNoVisibleWindows(child.pid);
}
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
} finally {
terminateChild();
}
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);