feat(release): add verified Linux packages

This commit is contained in:
NimBold
2026-07-12 06:03:54 +03:30
parent 7894c05bba
commit 5bbee12602
5 changed files with 302 additions and 5 deletions
+40 -2
View File
@@ -61,16 +61,38 @@ jobs:
libdbus-1-dev \
pkg-config \
xvfb \
libtinfo5
libtinfo5 \
rpm \
cpio \
desktop-file-utils \
xdg-utils
- run: npm ci
- name: Provision locked engines
if: runner.os != 'macOS'
run: node scripts/provision-engines.js --target ${{ matrix.target }}
- name: Build package
if: runner.os != 'Linux'
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: Build Linux native packages
if: runner.os == 'Linux'
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles deb,rpm
env:
APPIMAGE_EXTRACT_AND_RUN: 1
- name: Verify and preserve Linux native packages
if: runner.os == 'Linux'
run: |
node scripts/verify-linux-packages.js --target ${{ matrix.target }}
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
if: runner.os == 'Linux'
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles appimage
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:
@@ -128,6 +150,20 @@ jobs:
path: ${{ matrix.artifact }}
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v7
if: runner.os == 'Linux'
with:
name: Firelink-Linux-x64-Deb-${{ github.ref_name }}
path: ${{ runner.temp }}/firelink-native-packages/deb/*.deb
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v7
if: runner.os == 'Linux'
with:
name: Firelink-Linux-x64-RPM-${{ github.ref_name }}
path: ${{ runner.temp }}/firelink-native-packages/rpm/*.rpm
if-no-files-found: error
retention-days: 3
publish:
name: Publish GitHub release
@@ -169,6 +205,8 @@ jobs:
rename_asset '*.dmg' "Firelink_${VERSION}_macOS-ARM64.dmg"
rename_asset '*.AppImage' "Firelink_${VERSION}_Linux-x64.AppImage"
rename_asset '*.deb' "Firelink_${VERSION}_Linux-x64.deb"
rename_asset '*.rpm' "Firelink_${VERSION}_Linux-x64.rpm"
rename_asset '*.exe' "Firelink_${VERSION}_Windows-x64-setup.exe"
- name: Generate checksums
run: |
+4 -2
View File
@@ -57,10 +57,12 @@ Download desktop builds from [GitHub Releases](https://github.com/nimbold/Fireli
| --- | --- | --- |
| **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. |
| **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. |
| **Linux x64** | `.AppImage` | Make executable before launching if your desktop environment does not do that automatically. |
| **Linux x64** | `.deb`, `.rpm`, or `.AppImage` | Use `.deb` for Debian-family systems, `.rpm` for Fedora/RPM-family systems, or AppImage as the portable fallback. AppImage may need executable permission. |
Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager.
The native packages use the distribution's normal desktop runtime dependencies, while AppImage remains the portable installation option.
## Browser Extension
<p align="center">
@@ -91,7 +93,7 @@ The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-
| --- | --- |
| **macOS arm64** | Supported. Native build, engine checks, launch smoke test, ad-hoc-signed DMG workflow. |
| **Windows x64** | Supported. Native build, engine checks, silent installer smoke test, NSIS installer. |
| **Linux x64** | Supported. Native build, engine checks, xvfb launch smoke test, AppImage. |
| **Linux x64** | Supported. Native build, bundled-engine checks, package/AppImage launch smoke tests, `.deb`, `.rpm`, and AppImage. |
## Development
+4
View File
@@ -5,6 +5,8 @@ Targets:
- macOS arm64 DMG
- Windows x64 NSIS installer
- Linux x64 AppImage
- Linux x64 Debian package
- Linux x64 RPM package
## Distribution policy
@@ -22,6 +24,8 @@ Firelink never falls back to system-installed media tools.
- `scripts/stage-engines.js` creates one target-specific bundle payload.
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is built separately with the engine payload temporarily omitted, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
## Version update
+241
View File
@@ -0,0 +1,241 @@
#!/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(`[FAIL] ${message}`);
process.exit(1);
}
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}`);
}
return result;
}
function findSingle(directory, extension, label) {
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
fail(`${label} directory does not exist: ${directory}`);
}
const matches = fs.readdirSync(directory)
.filter(name => name.endsWith(extension))
.map(name => path.join(directory, name));
if (matches.length !== 1) {
fail(`Expected exactly one ${label}, found ${matches.length} in ${directory}`);
}
return matches[0];
}
function assertPackageListing(packageFile, packageType, expectedPath) {
const result = packageType === 'deb'
? run('dpkg-deb', ['--contents', packageFile], { stdio: 'pipe' })
: run('rpm', ['-qpl', packageFile], { stdio: 'pipe' });
const listing = result.stdout ?? '';
assertSafePackageListing(listing, packageType);
if (!listing.includes(expectedPath)) {
fail(`${packageType} package is missing ${expectedPath}`);
}
if (!/usr\/share\/applications\/[^/]+\.desktop/.test(listing)) {
fail(`${packageType} package is missing its desktop entry`);
}
}
function assertPackageRecommendations(packageFile, packageType) {
const result = packageType === 'deb'
? run('dpkg-deb', ['--field', packageFile, 'Recommends'], { stdio: 'pipe' })
: run('rpm', ['-qp', '--recommends', packageFile], { stdio: 'pipe' });
const recommendations = result.stdout ?? '';
const dependencyNames = packageType === 'deb'
? recommendations
.split(/[,|]/)
.map(value => value.trim().split(/\s+/, 1)[0]?.split(':', 1)[0])
: recommendations
.split('\n')
.map(value => value.trim().split(/\s+/, 1)[0]);
for (const dependency of ['desktop-file-utils', 'xdg-utils']) {
if (!dependencyNames.includes(dependency)) {
fail(`${packageType} package is missing its ${dependency} recommendation`);
}
}
}
function assertSafePackageListing(listing, packageType) {
const lines = listing.split('\n').filter(Boolean);
const paths = packageType === 'deb'
? lines.map(line => {
const marker = line.indexOf('./');
if (marker < 0) fail(`Could not parse a Debian package path: ${line}`);
return line.slice(marker + 2);
})
: lines.map(line => line.replace(/^\/+/, ''));
for (const packagePath of paths) {
const parts = packagePath.split('/');
if (parts.includes('..') || (parts[0] !== 'usr' && packagePath !== 'usr')) {
fail(`${packageType} package contains an unsafe path: ${packagePath}`);
}
}
}
function extractDeb(packageFile, destination) {
fs.mkdirSync(destination, { recursive: true });
run('dpkg-deb', ['--extract', packageFile, destination]);
}
function extractRpm(packageFile, destination) {
fs.mkdirSync(destination, { recursive: true });
run('bash', ['-o', 'pipefail', '-c', 'rpm2cpio "$1" | (cd "$2" && cpio --no-absolute-filenames -idm --quiet)', '--', packageFile, destination]);
}
function readPayloadManifest(root, label) {
const manifestPath = path.join(root, 'payload-manifest.json');
if (!fs.existsSync(manifestPath)) {
fail(`${label} payload manifest is missing`);
}
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch (error) {
fail(`${label} payload manifest is invalid: ${error.message}`);
}
return manifest;
}
function findPayloadRoot(root, target, label) {
const expectedBinary = `yt-dlp-${target}`;
const matches = [];
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const candidate = path.join(directory, entry.name);
if (!entry.isDirectory()) continue;
if (fs.existsSync(path.join(candidate, expectedBinary)) && fs.existsSync(path.join(candidate, 'payload-manifest.json'))) {
matches.push(candidate);
}
walk(candidate);
}
};
walk(root);
if (matches.length !== 1) {
fail(`Expected exactly one ${label} engine payload root, found ${matches.length}`);
}
return matches[0];
}
function assertPayloadMatchesSource(sourceRoot, packagedRoot, target, label) {
const sourceManifest = readPayloadManifest(sourceRoot, 'Provisioned engine');
if (sourceManifest.target !== target) {
fail(`Provisioned engine payload target mismatch: expected ${target}, got ${sourceManifest.target}`);
}
const packagedManifest = readPayloadManifest(packagedRoot, label);
if (packagedManifest.target !== target) {
fail(`${label} payload target mismatch: expected ${target}, got ${packagedManifest.target}`);
}
const expectedFiles = Object.keys(sourceManifest.files || {}).sort();
const packagedFiles = collectRegularFiles(packagedRoot, { ignoredNames: ['payload-manifest.json'] })
.map(file => path.relative(packagedRoot, file).split(path.sep).join('/'))
.sort();
if (JSON.stringify(packagedFiles) !== JSON.stringify(expectedFiles)) {
fail(`${label} payload files differ from the provisioned engine manifest`);
}
for (const relative of expectedFiles) {
const packagedFile = path.join(packagedRoot, relative);
if (sha256(packagedFile) !== sourceManifest.files[relative]) {
fail(`${label} payload checksum mismatch: ${relative}`);
}
}
}
function findExecutable(root) {
const candidates = [
path.join(root, 'usr', 'bin', 'firelink'),
path.join(root, 'usr', 'bin', 'Firelink'),
];
const executable = candidates.find(candidate => {
if (!fs.existsSync(candidate)) return false;
const stat = fs.lstatSync(candidate);
return stat.isFile() && !stat.isSymbolicLink();
});
if (!executable) {
fail(`Packaged Firelink executable was not found under ${root}`);
}
return executable;
}
function verifyExtractedPackage(packageType, packageFile, target, root) {
const sourceRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
const packagedRoot = findPayloadRoot(root, target, packageType);
assertPayloadMatchesSource(sourceRoot, packagedRoot, target, packageType);
run(process.execPath, [
path.join(repoRoot, 'scripts', 'verify-binaries.js'),
'--search-root',
root,
'--target',
target,
]);
const executable = findExecutable(root);
run('xvfb-run', [
'-a',
process.execPath,
path.join(repoRoot, 'scripts', 'smoke-packaged-app.js'),
'--executable',
executable,
], { env: { APPDIR: root } });
}
const target = argValue('--target');
if (!target) fail('Pass --target <Rust target triple>.');
if (os.platform() !== 'linux') fail('Linux package verification must run on Linux.');
const bundleRoot = path.join(repoRoot, 'src-tauri', 'target', target, 'release', 'bundle');
const deb = findSingle(path.join(bundleRoot, 'deb'), '.deb', 'Debian package');
const rpm = findSingle(path.join(bundleRoot, 'rpm'), '.rpm', 'RPM package');
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-linux-packages-'));
const debRoot = path.join(extractionRoot, 'deb');
const rpmRoot = path.join(extractionRoot, 'rpm');
try {
assertPackageListing(deb, 'deb', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
assertPackageRecommendations(deb, 'deb');
extractDeb(deb, debRoot);
verifyExtractedPackage('deb', deb, target, debRoot);
assertPackageListing(rpm, 'rpm', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
assertPackageRecommendations(rpm, 'rpm');
extractRpm(rpm, rpmRoot);
verifyExtractedPackage('rpm', rpm, target, rpmRoot);
console.log('Linux .deb and .rpm payload and launch verification passed.');
} finally {
fs.rmSync(extractionRoot, { recursive: true, force: true });
}
+13 -1
View File
@@ -13,12 +13,24 @@
]
},
"bundle": {
"targets": ["appimage"],
"targets": ["appimage", "deb", "rpm"],
"linux": {
"appimage": {
"files": {
"usr/share/metainfo/com.nimbold.firelink.metainfo.xml": "linux/com.nimbold.firelink.metainfo.xml"
}
},
"deb": {
"recommends": ["desktop-file-utils", "xdg-utils"],
"files": {
"/usr/share/metainfo/com.nimbold.firelink.metainfo.xml": "linux/com.nimbold.firelink.metainfo.xml"
}
},
"rpm": {
"recommends": ["desktop-file-utils", "xdg-utils"],
"files": {
"/usr/share/metainfo/com.nimbold.firelink.metainfo.xml": "linux/com.nimbold.firelink.metainfo.xml"
}
}
}
}