fix(ci): reuse Linux binary for AppImage packaging

This commit is contained in:
NimBold
2026-07-30 03:16:37 +03:30
parent 4e504b8e74
commit 31cf4a1c50
5 changed files with 220 additions and 11 deletions
+2 -3
View File
@@ -93,12 +93,11 @@ jobs:
mkdir -p "$RUNNER_TEMP/firelink-native-packages/deb" "$RUNNER_TEMP/firelink-native-packages/rpm"
cp src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb "$RUNNER_TEMP/firelink-native-packages/deb/"
cp src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm "$RUNNER_TEMP/firelink-native-packages/rpm/"
- name: Build Linux AppImage
- name: Build Linux AppImage from existing binary
if: runner.os == 'Linux'
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles appimage
run: node scripts/build-linux-appimage.js --target ${{ matrix.target }}
env:
APPIMAGE_EXTRACT_AND_RUN: 1
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: '1'
- name: Install pinned appimagetool (Linux only)
if: runner.os == 'Linux'
env:
-8
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
@@ -53,11 +52,4 @@ process.env.VITE_BUILD_ID = `${configuredBuildId || 'artifact'}-${randomUUID()}`
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.');
}
runNpmScript('build');
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env node
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
let activeChild;
let receivedSignal;
export const APPIMAGE_CONFIG = JSON.stringify({
bundle: {
resources: {
'engine-dist/': null,
},
},
});
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function signalExitCode(signal) {
return {
SIGHUP: 129,
SIGINT: 130,
SIGTERM: 143,
}[signal] ?? 1;
}
function npmInvocation(args) {
if (process.env.npm_execpath) {
return [process.execPath, [process.env.npm_execpath, ...args]];
}
if (process.platform === 'win32') {
return ['cmd.exe', ['/d', '/s', '/c', 'npm', ...args]];
}
return ['npm', args];
}
export function appImageBundleArguments(target) {
return [
'run',
'tauri',
'--',
'bundle',
'-vv',
'--target',
target,
'--bundles',
'appimage',
'--config',
APPIMAGE_CONFIG,
];
}
function run(command, args, options = {}) {
const child = spawn(command, args, {
cwd: options.cwd ?? repoRoot,
env: { ...process.env, ...options.env },
stdio: options.stdio ?? 'inherit',
windowsHide: true,
detached: process.platform !== 'win32',
});
activeChild = child;
return new Promise((resolve, reject) => {
let settled = false;
child.once('error', error => {
if (activeChild === child) activeChild = undefined;
if (!settled) {
settled = true;
reject(error);
}
});
child.once('close', (code, signal) => {
if (activeChild === child) activeChild = undefined;
if (!settled) {
settled = true;
resolve({ code, signal });
}
});
});
}
async function runChecked(command, args, label = command) {
let result;
try {
result = await run(command, args);
} catch (error) {
throw new Error(`Failed to run ${label}: ${error.message}`, { cause: error });
}
if (result.signal) {
const error = new Error(`${label} was terminated by ${result.signal}.`);
error.exitCode = signalExitCode(result.signal);
throw error;
}
if (result.code !== 0) {
const error = new Error(`${label} exited with status ${result.code}.`);
error.exitCode = result.code ?? 1;
throw error;
}
return result;
}
function assertSafeTarget(target) {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(target)) {
throw new Error(`Invalid target triple: ${target}`);
}
}
async function main() {
const target = argValue('--target') || process.env.FIRELINK_TARGET_TRIPLE;
if (!target) {
throw new Error('Pass --target <triple>.');
}
assertSafeTarget(target);
// The native-package build has already staged and verified the engines.
// Verify once more before creating the AppImage so a failed preparation
// cannot produce an artifact that later appears valid only because its
// payload is absent.
await runChecked(
process.execPath,
['scripts/verify-binaries.js', '--staged', '--target', target],
'staged engine verification'
);
if (receivedSignal) {
const error = new Error(`Build interrupted by ${receivedSignal}.`);
error.exitCode = signalExitCode(receivedSignal);
throw error;
}
const [npmCommand, npmArgs] = npmInvocation(appImageBundleArguments(target));
await runChecked(npmCommand, npmArgs, 'Tauri AppImage bundling');
if (receivedSignal) {
const error = new Error(`Build interrupted by ${receivedSignal}.`);
error.exitCode = signalExitCode(receivedSignal);
throw error;
}
console.log(`Built the Linux AppImage from the existing ${target} release binary.`);
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
const handleSignal = signal => {
receivedSignal ??= signal;
// npm and Tauri can have Rust/packaging descendants. On POSIX, the child
// is a detached process-group leader, so signal the whole group instead of
// leaving descendants running after the wrapper exits.
if (activeChild && activeChild.exitCode === null && activeChild.signalCode === null) {
try {
if (process.platform === 'win32' || !activeChild.pid) {
activeChild.kill(signal);
} else {
process.kill(-activeChild.pid, signal);
}
} catch (error) {
if (error.code !== 'ESRCH') {
console.error(`[WARN] Could not terminate the AppImage build process group: ${error.message}`);
}
}
} else {
process.exitCode = signalExitCode(signal);
}
};
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
process.once(signal, () => handleSignal(signal));
}
main()
.catch(error => {
console.error(`[FAIL] ${error.message}`);
process.exitCode = error.exitCode ?? 1;
})
.finally(() => {
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
process.removeAllListeners(signal);
}
});
}
+20
View File
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { APPIMAGE_CONFIG, appImageBundleArguments } from './build-linux-appimage.js';
test('AppImage config removes only engine-dist from Tauri resources', () => {
assert.deepEqual(JSON.parse(APPIMAGE_CONFIG), {
bundle: {
resources: {
'engine-dist/': null,
},
},
});
});
test('AppImage bundling uses the existing binary instead of tauri build', () => {
const args = appImageBundleArguments('x86_64-unknown-linux-gnu');
assert.equal(args[3], 'bundle');
assert.equal(args.includes('build'), false);
assert.deepEqual(args.slice(-2), ['--config', APPIMAGE_CONFIG]);
});
+2
View File
@@ -5334,6 +5334,7 @@ async fn wait_for_aria2_stopped(port: u16, secret: &str, gid: &str) -> Result<()
static NEXT_DOCK_BADGE_SESSION: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(1);
#[cfg(target_os = "macos")]
fn should_apply_dock_badge_update(
current_session: u64,
current_generation: u64,
@@ -8041,6 +8042,7 @@ mod tests {
assert!(!is_media_artifact_name("video.f1.backup", "video.mp4", "video"));
}
#[cfg(target_os = "macos")]
#[test]
fn dock_badge_updates_reject_stale_sessions_and_generations() {
assert!(should_apply_dock_badge_update(1, 99, 2, 1));