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
+26 -27
View File
@@ -59,6 +59,7 @@ jobs:
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
curl \
patchelf \
libdbus-1-dev \
pkg-config \
@@ -68,49 +69,46 @@ jobs:
- name: Provision locked engines
if: runner.os != 'macOS'
run: node scripts/provision-engines.js --target ${{ matrix.target }}
- name: Hide all engines from linuxdeploy (Linux only)
if: runner.os == 'Linux'
run: |
# Patch tauri.conf.json's beforeBuildCommand to empty engine-dist
# AFTER stage-engines.js verifies it, but BEFORE tauri builds the bundle.
# This hides all static binaries (ffmpeg, aria2c, etc) from linuxdeploy
# which corrupts them via patchelf. We will repack them into the AppImage later.
sed -i 's/npm run build/rm -rf src-tauri\/engine-dist\/* \&\& npm run build/' src-tauri/tauri.conf.json
- name: Build package
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
env:
APPIMAGE_EXTRACT_AND_RUN: 1
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: ${{ runner.os == 'Linux' && '1' || '' }}
- name: Install pinned appimagetool (Linux only)
if: runner.os == 'Linux'
env:
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
APPIMAGETOOL_SHA256: a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0
run: |
curl -fsSL "$APPIMAGETOOL_URL" -o "$RUNNER_TEMP/appimagetool"
echo "$APPIMAGETOOL_SHA256 $RUNNER_TEMP/appimagetool" | sha256sum -c -
chmod +x "$RUNNER_TEMP/appimagetool"
- name: Repack AppImage with engines (Linux only)
if: runner.os == 'Linux'
run: |
APPDIR="src-tauri/target/${{ matrix.target }}/release/bundle/appimage/Firelink.AppDir"
# Ensure target directory exists in AppDir
mkdir -p "$APPDIR/usr/lib/Firelink/engine-dist/${{ matrix.target }}"
# Copy everything directly from provisioned-engines into the AppDir
cp -r src-tauri/provisioned-engines/${{ matrix.target }}/* "$APPDIR/usr/lib/Firelink/engine-dist/${{ matrix.target }}/"
# Download appimagetool
wget -qO appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool
# Find the original AppImage name to overwrite
APPIMAGE=$(ls src-tauri/target/${{ matrix.target }}/release/bundle/appimage/*.AppImage | head -n 1)
# Repack the AppImage
env APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 ./appimagetool "$APPDIR" "$APPIMAGE"
run: node scripts/repack-linux-appimage-engines.js --target ${{ matrix.target }} --appimagetool "$RUNNER_TEMP/appimagetool"
- name: Verify macOS packaged engines and launch
if: runner.os == 'macOS'
run: |
APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app"
node scripts/verify-binaries.js --search-root "$APP" --target ${{ matrix.target }}
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
- name: Verify Windows installer payload
- name: Verify Windows installer payload and launch
if: runner.os == 'Windows'
shell: pwsh
run: |
$installer = Get-ChildItem "src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe" | Select-Object -First 1
7z x $installer.FullName "-o$env:RUNNER_TEMP/firelink-installer" -y
node scripts/verify-binaries.js --search-root "$env:RUNNER_TEMP/firelink-installer" --target ${{ matrix.target }}
if (-not $installer) { throw "Windows NSIS installer artifact was not produced." }
$extractRoot = "$env:RUNNER_TEMP/firelink-installer"
Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue
7z x $installer.FullName "-o$extractRoot" -y
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
$installRoot = "$env:RUNNER_TEMP\FirelinkSmoke"
Remove-Item -Recurse -Force $installRoot -ErrorAction SilentlyContinue
& $installer.FullName /S "/D=$installRoot"
if ($LASTEXITCODE -ne 0) { throw "Silent NSIS install failed with exit code $LASTEXITCODE." }
$exe = Get-ChildItem $installRoot -Recurse -File | Where-Object { $_.Name -ieq "firelink.exe" } | Sort-Object FullName | Select-Object -First 1
if (-not $exe) { throw "firelink.exe was not found under $installRoot after silent install." }
node scripts/smoke-packaged-app.js --executable $exe.FullName --assert-no-visible-child-windows
- name: Verify Linux AppImage payload and launch
if: runner.os == 'Linux'
run: |
@@ -124,6 +122,7 @@ jobs:
name: Firelink-${{ matrix.label }}-${{ github.ref_name }}
path: ${{ matrix.artifact }}
if-no-files-found: error
retention-days: 3
publish:
name: Publish GitHub release
+4 -4
View File
@@ -32,7 +32,7 @@
"postcss": "^8.5.15",
"tailwindcss": "^4.3.1",
"typescript": "^6.0.3",
"vite": "^8.1.0",
"vite": "^8.1.2",
"vitest": "^4.1.9"
},
"engines": {
@@ -1974,9 +1974,9 @@
}
},
"node_modules/vite": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.1.tgz",
"integrity": "sha512-X/05/cT+VITy2AeDc1der6smvGWWREtL4hPbPTaVbjSBuuWkmNOjR6HP3NzqcQA2nF6VHGUPaFRJyft/2AE9Kg==",
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz",
"integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
+1 -1
View File
@@ -59,7 +59,7 @@
"postcss": "^8.5.15",
"tailwindcss": "^4.3.1",
"typescript": "^6.0.3",
"vite": "^8.1.0",
"vite": "^8.1.2",
"vitest": "^4.1.9"
}
}
+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
+103 -14
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,8 +12,9 @@ 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',
@@ -21,26 +22,39 @@ const child = spawn(executable, [], {
...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) {
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`);
@@ -50,14 +64,75 @@ for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) {
}
} catch {}
}
if (readyPort === null) {
await new Promise(resolve => setTimeout(resolve, 250));
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}`);
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}`);
+1 -1
View File
@@ -6,7 +6,7 @@
"build": {
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "node scripts/stage-engines.js && node scripts/verify-binaries.js --staged && npm run build",
"beforeBuildCommand": "node scripts/before-tauri-build.js",
"frontendDist": "../dist"
},
"app": {