LΓͺ Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
23.3 kB
# Desktop release pipeline β€” self-updating binaries for mac/linux/windows.
#
# Triggers:
# - push of a tag matching `v*` (e.g. `v0.2.0`) β†’ full release, publishes
# artifacts + signed updater manifest (`latest.json`) to GH Releases.
# - workflow_dispatch β†’ on-demand build, uploads artifacts as workflow
# artifacts only (no release, no updater manifest).
#
# Strategy: matrix builds per target. Each runner produces a PyInstaller
# frozen backend + Tauri bundle. `tauri-apps/tauri-action` signs the updater
# payloads with TAURI_SIGNING_PRIVATE_KEY and uploads to the GH Release for
# the tag. The built-in updater plugin polls the release's `latest.json` on
# client boot.
#
# Windows/Linux support: first-pass enabled. Expect the first few runs on
# each to surface PyInstaller/Tauri issues that never showed up locally on
# macOS β€” iterate on CI.
name: Desktop Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
draft:
description: "Create as draft release (tag push only)"
required: false
default: "true"
permissions:
contents: write # needed to attach artifacts + updater manifest to GH Release
env:
# Run all JavaScript actions on Node 24 (GH deprecates Node 20 in Sep 2026).
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# Fast gating job β€” runs backend pytest + frontend node:test + tsc on a
# single Linux runner. The matrix build below waits on this via `needs:`
# so we don't burn 4Γ— platform-matrix minutes on a broken commit.
test:
name: Tests (backend + frontend)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
# enable-cache persists ~/.cache/uv keyed on uv.lock.
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
# Node 22 is needed for --experimental-strip-types so node:test can
# import .ts files directly from frontend/src/api/*.
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup Bun
uses: oven-sh/setup-bun@v1
# Backend tests need ffmpeg (subprocess calls in fixtures). Cache the
# resolved .debs so warm runs skip the apt-get update + install.
- name: System deps (ffmpeg)
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: ffmpeg
version: 1.0
- name: Install Python deps
run: uv sync
- name: Run pytest
run: uv run pytest tests/ -q --tb=short
- name: Cache bun deps
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install frontend deps
working-directory: frontend
run: bun install
# Single-sourced typecheck command (mirrors ci.yml). The `typecheck:ci`
# script in frontend/package.json sets `--checkJs false` so pre-existing
# JS-side errors don't block the release; only .ts/.tsx files gate.
# Drift between CI and release-time typecheck flags broke v0.3.x release
# runs β€” keep this command identical to ci.yml's step.
- name: Frontend typecheck
working-directory: frontend
run: bun run typecheck:ci
# Invoke node directly (not `bun run test`) because `bun run` auto-aliases
# `node` to `bun` in script bodies, and bun doesn't support
# --experimental-strip-types.
- name: Run frontend node:test
working-directory: frontend
run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs
build:
needs: test
strategy:
fail-fast: false
matrix:
include:
- os: macos-14
arch: aarch64-apple-darwin
label: "macOS Apple Silicon"
rust_target: aarch64-apple-darwin
bundles: "app,dmg,updater"
# macOS Intel dropped: Apple shipped the last Intel Mac in 2023 and
# Rosetta 2 runs the ARM build natively. macos-13 runner backlog
# was also blocking every release tag for ~10 min.
# Windows: force MSI bundling via --bundles. NSIS fails at makensis
# because our PyInstaller payload approaches its ~2 GB stub limit.
- os: windows-2022
arch: x86_64-pc-windows-msvc
label: "Windows x64"
rust_target: x86_64-pc-windows-msvc
bundles: "msi,updater"
# Linux: ship .deb + .AppImage. AppImage is universal (no distro
# package-manager dep), runs on any glibc-2.31+ host. Now viable
# because the thin uv-venv installer is ~10 MB (vs the prior ~2 GB
# PyInstaller payload that exceeded linuxdeploy limits). FUSE
# unavailability on GH runners handled via APPIMAGE_EXTRACT_AND_RUN=1.
- os: ubuntu-22.04
arch: x86_64-unknown-linux-gnu
label: "Linux x64"
rust_target: x86_64-unknown-linux-gnu
bundles: "deb,appimage,updater"
runs-on: ${{ matrix.os }}
name: ${{ matrix.label }}
steps:
- uses: actions/checkout@v4
# ── Language runtimes ──────────────────────────────────────────────
- name: Setup Rust (stable)
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_target }}
# Cache ~/.cargo/registry + {target}/ per rust_target. Cargo dep
# compile is the long pole of the build β€” cold is ~5-7 min, warm
# drops to ~1-2 min.
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: frontend/src-tauri -> target
key: ${{ matrix.rust_target }}
- name: Setup Bun
uses: oven-sh/setup-bun@v1
# ── Platform deps (Tauri host requirements only β€” no Python here) ─
# The runtime Python/uv bootstrap happens on the user's machine at
# first launch, not in CI. CI only packages the source (pyproject.toml,
# uv.lock, backend/*.py) into the Tauri installer as resources.
- name: macOS system deps
if: runner.os == 'macOS'
run: |
brew install ffmpeg || true
- name: Linux system deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential curl wget file libxdo-dev libssl-dev \
libayatana-appindicator3-dev librsvg2-dev \
libasound2-dev ffmpeg
# ── Frontend build ─────────────────────────────────────────────────
- name: Cache bun deps
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install frontend deps
working-directory: frontend
run: bun install
# ── Tauri build + sign + publish ───────────────────────────────────
# tauri-action handles: bundle, sign updater payload with the
# TAURI_SIGNING_PRIVATE_KEY secret, attach to release, update
# latest.json with per-platform download URLs & signatures. The
# installer ships the repo's pyproject.toml + uv.lock + backend/
# tree as Tauri resources; lib.rs::ensure_venv_ready recreates the
# venv on first launch via `uv sync --frozen --no-dev`.
# Fetch the standalone `uv` binary for the current matrix target and
# drop it at `binaries/uv-<rust-target-triple>{ext}`. tauri.conf.json
# references `binaries/uv` via `bundle.externalBin`, and tauri-bundler
# picks up the per-target file automatically. The runtime then uses
# the bundled binary instead of downloading uv on first launch.
#
# Pinned uv version mirrors the `UV_VERSION` constant in lib.rs; bump
# both together when refreshing.
- name: Bundle uv (${{ matrix.rust_target }})
shell: bash
env:
UV_VERSION: "0.11.7"
TRIPLE: ${{ matrix.rust_target }}
run: |
set -euo pipefail
mkdir -p frontend/src-tauri/binaries
case "$TRIPLE" in
aarch64-apple-darwin|x86_64-apple-darwin|x86_64-unknown-linux-gnu)
ARCHIVE="tar.gz"
;;
x86_64-pc-windows-msvc)
ARCHIVE="zip"
;;
*)
echo "Unsupported target for uv bundling: $TRIPLE"
exit 1
;;
esac
URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${TRIPLE}.${ARCHIVE}"
echo "Fetching $URL"
WORK=$(mktemp -d)
if [ "$ARCHIVE" = "zip" ]; then
curl -fsSL "$URL" -o "$WORK/uv.zip"
unzip -j -o "$WORK/uv.zip" -d "$WORK"
mv "$WORK/uv.exe" "frontend/src-tauri/binaries/uv-${TRIPLE}.exe"
else
curl -fsSL "$URL" | tar -xz -C "$WORK"
mv "$WORK/uv-${TRIPLE}/uv" "frontend/src-tauri/binaries/uv-${TRIPLE}"
chmod +x "frontend/src-tauri/binaries/uv-${TRIPLE}"
fi
ls -la "frontend/src-tauri/binaries/"
# Download static ffmpeg + ffprobe binaries and drop them into the
# Tauri sidecar directory. Sources:
# macOS: evermeet.cx β€” individual .zip per binary (x86_64,
# runs fine on Apple Silicon via Rosetta 2)
# Linux/Windows: BtbN/FFmpeg-Builds β€” single archive with both bins
# Pinned BtbN/FFmpeg-Builds version for Linux + Windows ffmpeg
# bundling. The string appears *twice* in each URL (once as the
# release tag, once inside the archive filename) β€” BtbN tags their
# autobuilds `autobuild-YYYY-MM-DD-HH-MM` and the inner filenames
# use the same datestamp. Driving both from one variable means a
# maintainer pin is a one-line edit: change `latest` to a specific
# autobuild tag (https://github.com/BtbN/FFmpeg-Builds/releases) to
# get reproducible installer builds. Same constant lives in
# frontend/src-tauri/src/tools.rs:FFMPEG_BTBN_VERSION β€” bump
# together.
- name: Bundle ffmpeg + ffprobe (${{ matrix.rust_target }})
shell: bash
env:
TRIPLE: ${{ matrix.rust_target }}
FFMPEG_BTBN_VERSION: "latest"
run: |
set -euo pipefail
BINDIR="frontend/src-tauri/binaries"
mkdir -p "$BINDIR"
WORK=$(mktemp -d)
case "$TRIPLE" in
aarch64-apple-darwin|x86_64-apple-darwin)
# evermeet.cx ships each binary as a separate .zip containing
# a single x86_64 Mach-O executable (runs via Rosetta on arm64).
for TOOL in ffmpeg ffprobe; do
if [ "$TOOL" = "ffmpeg" ]; then
URL="https://evermeet.cx/ffmpeg/getrelease/zip"
else
URL="https://evermeet.cx/ffmpeg/getrelease/${TOOL}/zip"
fi
echo "Fetching $TOOL from evermeet.cx"
curl -fsSL "$URL" -o "$WORK/${TOOL}.zip"
unzip -o -j "$WORK/${TOOL}.zip" -d "$WORK"
mv "$WORK/${TOOL}" "$BINDIR/${TOOL}-${TRIPLE}"
chmod +x "$BINDIR/${TOOL}-${TRIPLE}"
done
;;
x86_64-unknown-linux-gnu)
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-linux64-gpl.tar.xz"
echo "Fetching ffmpeg from BtbN (linux64) β€” version=${FFMPEG_BTBN_VERSION}"
curl -fsSL "$URL" -o "$WORK/ffmpeg.tar.xz"
tar -xJf "$WORK/ffmpeg.tar.xz" -C "$WORK"
# Archive extracts to ffmpeg-master-latest-linux64-gpl/bin/
EXTRACTED=$(find "$WORK" -type d -name "bin" | head -1)
mv "$EXTRACTED/ffmpeg" "$BINDIR/ffmpeg-${TRIPLE}"
mv "$EXTRACTED/ffprobe" "$BINDIR/ffprobe-${TRIPLE}"
chmod +x "$BINDIR/ffmpeg-${TRIPLE}" "$BINDIR/ffprobe-${TRIPLE}"
;;
x86_64-pc-windows-msvc)
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-win64-gpl.zip"
echo "Fetching ffmpeg from BtbN (win64) β€” version=${FFMPEG_BTBN_VERSION}"
curl -fsSL "$URL" -o "$WORK/ffmpeg.zip"
unzip -o "$WORK/ffmpeg.zip" -d "$WORK"
EXTRACTED=$(find "$WORK" -type f -name "ffmpeg.exe" | head -1)
EXTRACTED_DIR=$(dirname "$EXTRACTED")
mv "$EXTRACTED_DIR/ffmpeg.exe" "$BINDIR/ffmpeg-${TRIPLE}.exe"
mv "$EXTRACTED_DIR/ffprobe.exe" "$BINDIR/ffprobe-${TRIPLE}.exe"
;;
*)
echo "⚠ No ffmpeg bundling for target: $TRIPLE (will download at first run)"
;;
esac
ls -la "$BINDIR/"
# Extract the matching CHANGELOG.md section so the release body has
# real notes instead of "see commit log". Falls back to a one-liner
# if the tag has no matching `## [X.Y.Z]` section yet β€” keeps the
# release publishable even when CHANGELOG hasn't been updated.
- name: Extract CHANGELOG section for tag
id: changelog
shell: bash
run: |
TAG="${GITHUB_REF_NAME#v}"
BODY=""
if [ -f CHANGELOG.md ]; then
BODY=$(awk -v tag="$TAG" '
/^## \[/ {
if (in_section) exit
if ($0 ~ "\\[" tag "\\]") { in_section = 1; next }
}
in_section { print }
' CHANGELOG.md | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
fi
if [ -z "$BODY" ]; then
BODY="Auto-generated release for ${GITHUB_REF_NAME}. See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/main/CHANGELOG.md) and the commit log for details."
fi
{
echo 'body<<RELEASE_BODY_EOF'
echo "$BODY"
echo 'RELEASE_BODY_EOF'
} >> "$GITHUB_OUTPUT"
- name: Build + release (Tauri)
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# GH runners disable FUSE, so linuxdeploy's AppImage can't mount
# itself at bundle time. This env tells linuxdeploy to extract-and-run
# instead, which works without FUSE.
APPIMAGE_EXTRACT_AND_RUN: 1
with:
projectPath: frontend
args: --target ${{ matrix.rust_target }} --bundles ${{ matrix.bundles }}
tagName: ${{ github.ref_name }}
releaseName: "OmniVoice Studio ${{ github.ref_name }}"
releaseBody: ${{ steps.changelog.outputs.body }}
releaseDraft: ${{ inputs.draft || 'true' }}
prerelease: false
updaterJsonPreferNsis: false
includeUpdaterJson: true
# ── Installer smoke (Phase 0 GATE-03) ─────────────────────────────
# Boot the just-built bundle on this matrix leg, poll /health, fail
# the release if it doesn't come up. Catches bundle-only regressions
# (PyInstaller missing-module, Tauri sidecar path mismatch, etc.)
# that the in-process smoke matrix on ci.yml cannot see.
- name: Installer smoke (macOS)
if: runner.os == 'macOS'
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
DMG=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg -name "*.dmg" | head -1)
echo "Smoke-testing DMG: $DMG"
MOUNT=$(hdiutil attach -nobrowse -readonly "$DMG" | tail -1 | awk '{print $3}')
APP=$(find "$MOUNT" -maxdepth 2 -name "*.app" | head -1)
# RESEARCH Pitfall #5: do NOT launch the Tauri WebView shell on a headless runner β€” it hangs.
# The `--health-check` flag lives in backend/main.py (Python), not the Rust WebView main.
# Strategy: invoke the bundled Python backend directly, bypassing Tauri's window code.
BACKEND=$(find "$APP/Contents" -type f \( -name 'backend' -o -name 'backend.app' -o -name 'main.py' \) -perm +111 2>/dev/null | head -1)
if [ -z "$BACKEND" ]; then
# Fallback: try the PyInstaller sidecar location Tauri uses.
BACKEND=$(find "$APP/Contents/Resources" -type f \( -name 'backend*' -o -name 'omnivoice*' \) -perm +111 2>/dev/null | head -1)
fi
if [ -z "$BACKEND" ]; then
echo "FAIL β€” could not locate bundled backend binary in $APP. Contents:"
find "$APP/Contents" -type f -perm +111 | head -30
hdiutil detach "$MOUNT" || true
exit 1
fi
echo "Launching bundled backend: $BACKEND --health-check"
"$BACKEND" --health-check
EXIT=$?
hdiutil detach "$MOUNT" || true
exit $EXIT
- name: Installer smoke (Windows)
if: runner.os == 'Windows'
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" | head -1)
echo "Smoke-testing MSI: $MSI"
# /quiet = no UI, /norestart = don't reboot the runner if a dep asks
msiexec.exe //i "$(cygpath -w "$MSI")" //quiet //norestart
# Tauri installs to "Program Files\OmniVoice Studio\..." by default. Backend is a sidecar binary
# (RESEARCH Pitfall #5) β€” not the Tauri WebView .exe β€” so locate by name pattern.
BACKEND=$(find "/c/Program Files/OmniVoice Studio" -type f \( -name 'backend.exe' -o -name 'omnivoice-backend.exe' -o -name 'main.exe' \) 2>/dev/null | head -1)
if [ -z "$BACKEND" ]; then
echo "FAIL β€” bundled backend .exe not found under C:/Program Files/OmniVoice Studio. Contents:"
find "/c/Program Files/OmniVoice Studio" -type f -name '*.exe' | head -20
exit 1
fi
echo "Launching bundled backend: $BACKEND --health-check"
"$BACKEND" --health-check &
BACKEND_PID=$!
# Wait for completion (--health-check is a short-lived, exits-after-200 invocation)
wait $BACKEND_PID
EXIT=$?
# RESEARCH Pitfall #2: cleanup orphaned PyInstaller child processes on port 3900.
# Safe on GH-hosted ephemeral runners; REQUIRED if/when we move to self-hosted Windows.
taskkill //F //T //PID $BACKEND_PID 2>/dev/null || echo "backend process already exited cleanly"
exit $EXIT
- name: Installer smoke (Linux)
if: runner.os == 'Linux'
timeout-minutes: 5
shell: bash
run: |
set -euo pipefail
# Use the AppImage β€” single-file, no installer needed.
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
echo "Smoke-testing AppImage: $APPIMAGE"
chmod +x "$APPIMAGE"
# GH runners have no FUSE β€” extract before running (mirrors APPIMAGE_EXTRACT_AND_RUN=1 used at build time).
EXTRACT_DIR="$(mktemp -d)"
cd "$EXTRACT_DIR"
"$APPIMAGE" --appimage-extract >/dev/null
# Tauri's AppRun lives at squashfs-root/AppRun; the actual binary is in squashfs-root/usr/bin/
BIN=$(find squashfs-root -type f -name "OmniVoice Studio" -o -name "omnivoice-studio" 2>/dev/null | head -1)
if [ -z "$BIN" ]; then
BIN="$EXTRACT_DIR/squashfs-root/AppRun"
fi
echo "Launching under xvfb-run: $BIN --health-check"
sudo apt-get install -y xvfb >/dev/null 2>&1 || true
xvfb-run -a "$BIN" --health-check
# ── Compute SHA-256 checksums (Phase 0 GATE-05) ───────────────────
# Native OS tools: shasum -a 256 (POSIX) / Get-FileHash (Windows).
# Writes SHA256SUMS-<label>.txt for the user-verifiable path AND
# captures the content into $GITHUB_OUTPUT for body append.
- name: Compute SHA-256 checksums
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
id: checksums
shell: bash
run: |
set -euo pipefail
BUNDLE_DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle"
OUT="SHA256SUMS-${{ matrix.label }}.txt"
# Gather artifact paths per matrix leg's `bundles` (msi/app/dmg/deb/appimage/updater).
# `find` is portable across all three runners (Git Bash on Windows).
mapfile -t ARTIFACTS < <(find "$BUNDLE_DIR" -type f \
\( -name "*.dmg" -o -name "*.app.tar.gz" -o -name "*.app.tar.gz.sig" \
-o -name "*.msi" -o -name "*.msi.sig" \
-o -name "*.AppImage" -o -name "*.AppImage.sig" \
-o -name "*.deb" \) 2>/dev/null | sort)
if [ ${#ARTIFACTS[@]} -eq 0 ]; then
echo "FAIL β€” no artifacts found under $BUNDLE_DIR"
find "$BUNDLE_DIR" -type f | head -50
exit 1
fi
# shasum is available on macOS by default, on ubuntu-22.04 (perl pkg),
# and on windows-2022 Git Bash. Falls back to sha256sum on Linux.
if command -v shasum >/dev/null 2>&1; then
HASHER="shasum -a 256"
else
HASHER="sha256sum"
fi
{
echo "### ${{ matrix.label }} artifacts"
echo ""
echo '```'
for f in "${ARTIFACTS[@]}"; do
# Strip the long bundle prefix from the printed path for readability;
# the hash itself is computed against the full file.
( cd "$(dirname "$f")" && $HASHER "$(basename "$f")" )
done
echo '```'
echo ""
} | tee "$OUT"
echo "checksums_file=$OUT" >> "$GITHUB_OUTPUT"
- name: Append checksums to release + attach SHA256SUMS file
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
append_body: true
body_path: ${{ steps.checksums.outputs.checksums_file }}
files: ${{ steps.checksums.outputs.checksums_file }}
fail_on_unmatched_files: true