From a300e440e037ce36c3a270b60944a3b39792cf87 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 17 Jun 2026 19:18:39 +0330 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 90 ++++++++++ RELEASE.md | 74 ++++++++ scripts/verify-binaries.js | 318 +++++++++++++++++++++++++++++++--- 4 files changed, 457 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 RELEASE.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17e0a9f..9d196c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ebd2863 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..15340b8 --- /dev/null +++ b/RELEASE.md @@ -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 +git push origin v +``` + +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. diff --git a/scripts/verify-binaries.js b/scripts/verify-binaries.js index 7865aa7..8a7503a 100644 --- a/scripts/verify-binaries.js +++ b/scripts/verify-binaries.js @@ -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.'); -process.exit(0); +// ───── 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); +}