Adds a user-selectable updater release channel (Settings -> About -> Update channel). Stable (default, every install + launch) tracks tagged vX.Y.Z releases; Preview tracks the latest main build via a rolling "preview" prerelease, falling back to stable if a stable release is ahead. Why Rust: tauri-plugin-updater reads its endpoints from tauri.conf.json and neither the JS check() nor the plugin's registration Builder can change them at runtime (verified against the 2.10.1 source). The only runtime-endpoint API is UpdaterExt::endpoints, so check+install move into two Rust commands that mirror the plugin's own check/download_and_install -- the Stable path behaves identically to the JS flow it replaces; only which manifest is consulted changes. Switching is instant (channel is read per check), no restart. backend (Rust): - config.rs: update_channel field (default "stable", VALID_CHANNELS) + get/set_update_channel commands. - updater_channel.rs: channel_endpoints() (preview -> [preview, stable]) + check_update / install_update commands; install emits update://progress. frontend: - utils/updateChannel.js (+test): single source of truth, normalizeChannel. - utils/updater.js: routes the badge flow (#198) through the Rust commands via the same store contract -- UpdateBadge/App.jsx unchanged. - Settings About: Stable/Preview segmented toggle, channel-aware endpoint row + diagnostics; Check-for-updates honors the live channel. - i18n en + zh-CN. release.yml: additive, workflow_dispatch-guarded preview publish to a rolling "preview" prerelease. The v* tag-push stable path evaluates to its exact prior values (verified) and is never affected. Preview builds are manual -- no scheduled CI spend, nothing auto-published. docs/update-channels.md. Verified: cargo check (compiles clean), tsc, vitest 162/162, build, CJK guard, release.yml YAML parses. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
546 lines
25 KiB
YAML
546 lines
25 KiB
YAML
# Desktop release pipeline — self-updating binaries for mac/linux/windows.
|
||
#
|
||
# Triggers:
|
||
# - push of a tag matching `v*` (e.g. `v0.2.0`) → full STABLE release,
|
||
# publishes artifacts + signed updater manifest (`latest.json`) to the
|
||
# tag's GH Release. This is the default Stable updater channel.
|
||
# - workflow_dispatch (publish_preview=true) → builds the selected branch and
|
||
# publishes a rolling `preview` PRERELEASE with its own signed
|
||
# `latest.json` at releases/download/preview/. This feeds the opt-in
|
||
# Preview updater channel (Settings → About → Update channel). The stable
|
||
# `latest` release is untouched. Run this manually whenever you want to cut
|
||
# a preview from `main`.
|
||
# - workflow_dispatch (publish_preview=false) → on-demand build (prior
|
||
# behavior; draft release named after the branch).
|
||
#
|
||
# 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"
|
||
publish_preview:
|
||
description: "Publish a rolling 'preview' prerelease (updater Preview channel) from the selected branch"
|
||
required: false
|
||
type: boolean
|
||
default: false
|
||
|
||
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 }}
|
||
# macOS Developer-ID code-signing + notarization (#134 / #72).
|
||
# tauri-action signs + notarizes the .app/.dmg ONLY when these are
|
||
# non-empty; with the secrets unset they resolve to empty strings and
|
||
# the build stays unsigned (today's behavior — users clear quarantine
|
||
# via `xattr -cr`, see docs/install/macos.md). To enable, add the repo
|
||
# secrets documented in docs/install/macos.md → "For maintainers".
|
||
# No-op on the Windows/Linux matrix legs.
|
||
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 }}
|
||
# 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 }}
|
||
# Preview path (workflow_dispatch + publish_preview=true) targets a
|
||
# rolling `preview` prerelease for the updater's Preview channel.
|
||
# Every other invocation — crucially the `v*` tag-push stable release
|
||
# — evaluates these expressions to exactly their prior values.
|
||
tagName: ${{ (github.event_name == 'workflow_dispatch' && inputs.publish_preview) && 'preview' || github.ref_name }}
|
||
releaseName: ${{ (github.event_name == 'workflow_dispatch' && inputs.publish_preview) && 'OmniVoice Studio (Preview)' || format('OmniVoice Studio {0}', github.ref_name) }}
|
||
releaseBody: ${{ steps.changelog.outputs.body }}
|
||
releaseDraft: ${{ (github.event_name == 'workflow_dispatch' && inputs.publish_preview) && 'false' || (inputs.draft || 'true') }}
|
||
prerelease: ${{ (github.event_name == 'workflow_dispatch' && inputs.publish_preview) || 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
|