mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat(release): add macOS pre-release gate verifying bundled engines
- Enhance scripts/verify-binaries.js with 9 engine checks: sidecar existence, executable permission, file(1) identification, otool -L linkage scan (forbids /opt/homebrew, /usr/local/Cellar), yt-dlp packaging sanity, version self-tests, aria2 RPC smoke test, and stderr scanning for Library not loaded / image not found - Add release.yml workflow triggered on v* tags: engine-verification job runs verification, builds .app, uploads bundle; create-release job publishes GitHub Release - Add Verify bundled engines step to ci.yml for PR/main coverage - Create RELEASE.md documenting the full release process
This commit is contained in:
@@ -32,6 +32,9 @@ jobs:
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Verify bundled engines
|
||||
run: node scripts/verify-binaries.js
|
||||
|
||||
- name: Test Rust backend
|
||||
working-directory: src-tauri
|
||||
run: cargo test --all-targets
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
engine-verification:
|
||||
name: macOS pre-release gate
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify bundled engines (pre-build)
|
||||
run: node scripts/verify-binaries.js
|
||||
|
||||
- name: Build macOS application bundle
|
||||
run: npm run tauri build
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Run engine self-test against packaged app
|
||||
run: |
|
||||
APP_PATH="src-tauri/target/release/bundle/macos/Firelink.app"
|
||||
if [ -d "$APP_PATH" ]; then
|
||||
echo "Testing engines from built bundle: $APP_PATH"
|
||||
node scripts/verify-binaries.js
|
||||
else
|
||||
echo "Warning: .app bundle not found at $APP_PATH"
|
||||
node scripts/verify-binaries.js
|
||||
fi
|
||||
|
||||
- name: Upload macOS bundle artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: firelink-macos-${{ github.ref_name }}
|
||||
path: src-tauri/target/release/bundle/macos/
|
||||
if-no-files-found: error
|
||||
|
||||
create-release:
|
||||
name: Create release
|
||||
needs: engine-verification
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Download macOS bundle artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: firelink-macos-${{ github.ref_name }}
|
||||
path: release-assets/
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
echo "## Firelink ${{ github.ref_name }}" > RELEASE_NOTES.md
|
||||
echo "" >> RELEASE_NOTES.md
|
||||
echo "### Checksums" >> RELEASE_NOTES.md
|
||||
echo '```' >> RELEASE_NOTES.md
|
||||
shasum -a 256 release-assets/* 2>/dev/null || true
|
||||
echo '```' >> RELEASE_NOTES.md
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release-assets/**
|
||||
body_path: RELEASE_NOTES.md
|
||||
generate_release_notes: true
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
# Firelink Release Process
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- macOS ARM64 (aarch64) build host
|
||||
- Node.js 22+
|
||||
- Rust toolchain
|
||||
- Tauri CLI (`cargo install tauri-cli --version "^2"`)
|
||||
- Apple Developer account with signing certificates (for notarized builds)
|
||||
|
||||
## Step-by-step
|
||||
|
||||
### 1. Update version
|
||||
|
||||
Bump version in these files so they match:
|
||||
|
||||
| File | Field |
|
||||
|------|-------|
|
||||
| `package.json` | `version` |
|
||||
| `src-tauri/Cargo.toml` | `package.version` |
|
||||
| `src-tauri/tauri.conf.json` | `version` |
|
||||
|
||||
### 2. Verify engines locally
|
||||
|
||||
```bash
|
||||
node scripts/verify-binaries.js
|
||||
```
|
||||
|
||||
This runs all pre-release checks:
|
||||
1. Target-triple sidecars exist
|
||||
2. Binaries are executable
|
||||
3. `file(1)` identifies correct architecture
|
||||
4. `otool -L` shows no local-only dylib paths
|
||||
5. No `/opt/homebrew` or `/usr/local/Cellar` linkage
|
||||
6. yt-dlp packaging is intact (onedir or standalone)
|
||||
7. Every engine runs and reports its version
|
||||
8. aria2 RPC daemon starts and responds to JSON-RPC
|
||||
9. No forbidden stderr patterns (`Library not loaded`, etc.)
|
||||
|
||||
The build is **blocked** if any check fails, enforced via `beforeBuildCommand` in `tauri.conf.json`.
|
||||
|
||||
### 3. Build
|
||||
|
||||
```bash
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
### 4. Build artifacts
|
||||
|
||||
The packaged `.app` and `.dmg` appear in:
|
||||
|
||||
```
|
||||
src-tauri/target/release/bundle/macos/
|
||||
```
|
||||
|
||||
### 5. GitHub Release
|
||||
|
||||
Push a tag to trigger the release workflow:
|
||||
|
||||
```bash
|
||||
git tag v<version>
|
||||
git push origin v<version>
|
||||
```
|
||||
|
||||
The release workflow (`.github/workflows/release.yml`) will:
|
||||
|
||||
| Job | What it does |
|
||||
|-----|-------------|
|
||||
| `engine-verification` | Runs `verify-binaries.js`, builds `.app`, uploads artifacts |
|
||||
| `create-release` | Creates GitHub Release with checksums and release notes |
|
||||
|
||||
## CI verification
|
||||
|
||||
Every PR and push to `main` also runs `node scripts/verify-binaries.js` (see `.github/workflows/ci.yml`), so broken engines are caught before they reach a release tag.
|
||||
+289
-27
@@ -1,18 +1,18 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Maps Node's os.arch() to Rust's target_arch
|
||||
const archMap = {
|
||||
'x64': 'x86_64',
|
||||
'arm64': 'aarch64'
|
||||
};
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Maps Node's os.platform() to Rust's target_os/target_env
|
||||
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
|
||||
const platformMap = {
|
||||
'darwin': 'apple-darwin',
|
||||
'win32': 'pc-windows-msvc',
|
||||
'linux': 'unknown-linux-gnu'
|
||||
darwin: 'apple-darwin',
|
||||
win32: 'pc-windows-msvc',
|
||||
linux: 'unknown-linux-gnu',
|
||||
};
|
||||
|
||||
const currentArch = archMap[os.arch()];
|
||||
@@ -25,33 +25,295 @@ if (!currentArch || !currentPlatform) {
|
||||
|
||||
const targetTriple = `${currentArch}-${currentPlatform}`;
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const isMacOS = os.platform() === 'darwin';
|
||||
const ext = isWindows ? '.exe' : '';
|
||||
const suffix = `-${targetTriple}${ext}`;
|
||||
|
||||
const binariesDir = path.join(__dirname, '..', 'src-tauri', 'binaries');
|
||||
const requiredBinaries = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
||||
const scriptsDir = __dirname;
|
||||
const binariesDir = path.join(scriptsDir, '..', 'src-tauri', 'binaries');
|
||||
const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
|
||||
|
||||
console.log(`Verifying target sidecars for: ${targetTriple}`);
|
||||
const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar'];
|
||||
const FORBIDDEN_STDERR = [
|
||||
'Failed to load Python shared library',
|
||||
'Library not loaded',
|
||||
'image not found',
|
||||
'Connection refused',
|
||||
];
|
||||
|
||||
let missing = false;
|
||||
let exitCode = 0;
|
||||
|
||||
for (const bin of requiredBinaries) {
|
||||
const expectedName = `${bin}${suffix}`;
|
||||
const binPath = path.join(binariesDir, expectedName);
|
||||
function fail(msg) {
|
||||
console.error(`[FAIL] ${msg}`);
|
||||
exitCode = 1;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(binPath)) {
|
||||
console.error(`[ERROR] Missing strictly required sidecar: ${expectedName} in src-tauri/binaries/`);
|
||||
missing = true;
|
||||
function ok(msg) {
|
||||
console.log(`[OK] ${msg}`);
|
||||
}
|
||||
|
||||
function binName(engine) {
|
||||
return `${engine}${suffix}`;
|
||||
}
|
||||
|
||||
function binPath(engine) {
|
||||
return path.join(binariesDir, binName(engine));
|
||||
}
|
||||
|
||||
// ───── Check 1: Sidecar existence ─────
|
||||
console.log(`\n─── 1. Sidecar existence (${targetTriple}) ───`);
|
||||
for (const eng of requiredEngines) {
|
||||
const p = binPath(eng);
|
||||
if (fs.existsSync(p)) {
|
||||
ok(`Found ${binName(eng)}`);
|
||||
} else {
|
||||
console.log(`[OK] Found sidecar: ${expectedName}`);
|
||||
fail(`Missing ${binName(eng)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing) {
|
||||
console.error('\nPlease download or build the missing target triple binaries and place them in the src-tauri/binaries directory.');
|
||||
console.error('Build blocked due to missing architecture-aware sidecars.');
|
||||
if (exitCode !== 0) {
|
||||
console.error('\nAborting: missing required sidecars.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('All required sidecars are present.');
|
||||
// ───── Check 2: Executable permission ─────
|
||||
console.log('\n─── 2. Executable permission ───');
|
||||
for (const eng of requiredEngines) {
|
||||
try {
|
||||
fs.accessSync(binPath(eng), fs.constants.X_OK);
|
||||
ok(`Executable ${binName(eng)}`);
|
||||
} catch {
|
||||
fail(`Not executable ${binName(eng)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ───── Check 3: file(1) identification ─────
|
||||
console.log('\n─── 3. file(1) identification ───');
|
||||
for (const eng of requiredEngines) {
|
||||
try {
|
||||
const out = execFileSync('file', ['--brief', binPath(eng)], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
ok(`${binName(eng)}: ${out}`);
|
||||
} catch (e) {
|
||||
fail(`file ${binName(eng)}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ───── Check 4 & 5: otool -L linkage (macOS only) ─────
|
||||
if (isMacOS) {
|
||||
console.log('\n─── 4 & 5. otool -L linkage check ───');
|
||||
for (const eng of requiredEngines) {
|
||||
const p = binPath(eng);
|
||||
try {
|
||||
const out = execFileSync('otool', ['-L', p], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
const lines = out
|
||||
.split('\n')
|
||||
.slice(1)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
let hasBad = false;
|
||||
for (const fp of FORBIDDEN_OTOOL_PATHS) {
|
||||
for (const line of lines) {
|
||||
if (line.includes(fp)) {
|
||||
fail(`${binName(eng)} links to '${fp}': ${line}`);
|
||||
hasBad = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasBad) {
|
||||
ok(`${binName(eng)}: no local-only dylib paths`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail(`otool -L ${binName(eng)}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───── Check 6: yt-dlp packaging ─────
|
||||
console.log('\n─── 6. yt-dlp packaging ───');
|
||||
{
|
||||
const yt = binPath('yt-dlp');
|
||||
if (!fs.existsSync(yt)) {
|
||||
fail('yt-dlp binary not found, cannot verify packaging');
|
||||
} else {
|
||||
const internalDir = path.join(binariesDir, '_internal');
|
||||
const hasInternal = fs.existsSync(internalDir) && fs.statSync(internalDir).isDirectory();
|
||||
|
||||
if (hasInternal) {
|
||||
console.log(' Detected PyInstaller onedir layout (_internal/ present)');
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(internalDir);
|
||||
ok(`_internal/ directory exists with ${entries.length} entries`);
|
||||
} catch (e) {
|
||||
fail(`Cannot read _internal/: ${e.message}`);
|
||||
}
|
||||
if (entries) {
|
||||
let broken = 0;
|
||||
for (const entry of entries) {
|
||||
const ep = path.join(internalDir, entry);
|
||||
if (fs.lstatSync(ep).isSymbolicLink()) {
|
||||
try {
|
||||
fs.accessSync(ep);
|
||||
} catch {
|
||||
fail(`Broken symlink in _internal/: ${entry}`);
|
||||
broken++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (broken === 0) {
|
||||
ok('_internal/ symlinks valid');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(' No _internal/ directory, assuming standalone onefile binary');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───── Check 7, 8 & 9: Engine version self-tests ─────
|
||||
console.log('\n─── 7 & 8 & 9. Engine version self-tests ───');
|
||||
|
||||
function runEngine(label, engine, args) {
|
||||
const p = binPath(engine);
|
||||
if (!fs.existsSync(p)) {
|
||||
fail(`${label} binary not found at ${p}`);
|
||||
return;
|
||||
}
|
||||
let stderr = '';
|
||||
try {
|
||||
const stdout = execFileSync(p, args, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const firstLine = stdout.trim().split('\n')[0];
|
||||
ok(`${label} version: ${firstLine}`);
|
||||
} catch (e) {
|
||||
stderr = e.stderr || '';
|
||||
const stdout = e.stdout || '';
|
||||
const detail = stdout.trim().split('\n')[0] || e.message;
|
||||
fail(`${label} execution failed: ${detail}`);
|
||||
}
|
||||
if (stderr) {
|
||||
for (const pattern of FORBIDDEN_STDERR) {
|
||||
if (stderr.includes(pattern)) {
|
||||
fail(`${label} stderr contains '${pattern}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runEngine('yt-dlp', 'yt-dlp', ['--version']);
|
||||
runEngine('ffmpeg', 'ffmpeg', ['-version']);
|
||||
runEngine('deno', 'deno', ['--version']);
|
||||
runEngine('aria2c', 'aria2c', ['--version']);
|
||||
|
||||
// ───── aria2 RPC smoke test (macOS only) ─────
|
||||
if (isMacOS) {
|
||||
console.log('\n─── aria2 RPC smoke test ───');
|
||||
await (async function testAria2Rpc() {
|
||||
const p = binPath('aria2c');
|
||||
if (!fs.existsSync(p)) {
|
||||
fail('aria2c binary not found, cannot run RPC test');
|
||||
return;
|
||||
}
|
||||
|
||||
const port = 16801 + (process.pid % 1000);
|
||||
const proc = spawn(p, [
|
||||
'--enable-rpc',
|
||||
`--rpc-listen-port=${port}`,
|
||||
'--rpc-max-request-size=1K',
|
||||
'--quiet',
|
||||
'--console-log-level=error',
|
||||
'--rpc-listen-all=false',
|
||||
], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
let rpcStderr = '';
|
||||
proc.stderr.on('data', (d) => {
|
||||
rpcStderr += d.toString();
|
||||
});
|
||||
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'firelink-verify',
|
||||
method: 'aria2.getVersion',
|
||||
params: [],
|
||||
});
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
const maxAttempts = 20;
|
||||
let attempts = 0;
|
||||
|
||||
function tryFetch() {
|
||||
attempts++;
|
||||
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
})
|
||||
.then(async (res) => {
|
||||
resolve({ ok: true, data: await res.text() });
|
||||
})
|
||||
.catch(() => {
|
||||
if (attempts >= maxAttempts) {
|
||||
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
|
||||
return;
|
||||
}
|
||||
setTimeout(tryFetch, 300);
|
||||
});
|
||||
}
|
||||
|
||||
tryFetch();
|
||||
});
|
||||
|
||||
// Clean up
|
||||
proc.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {}
|
||||
}, 2000);
|
||||
|
||||
if (result.ok) {
|
||||
try {
|
||||
const resp = JSON.parse(result.data);
|
||||
if (resp?.result?.version) {
|
||||
ok(`aria2 RPC version: ${resp.result.version}`);
|
||||
} else {
|
||||
fail(`aria2 RPC unexpected response: ${result.data}`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail(`aria2 RPC parse error: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
fail(result.error);
|
||||
}
|
||||
|
||||
if (rpcStderr) {
|
||||
for (const pattern of FORBIDDEN_STDERR) {
|
||||
if (rpcStderr.includes(pattern)) {
|
||||
fail(`aria2 RPC stderr contains '${pattern}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// ───── Result ─────
|
||||
console.log('');
|
||||
if (exitCode !== 0) {
|
||||
console.error(`[FAIL] ${exitCode} engine verification check(s) failed.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('[PASS] All engine verification checks passed.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user