Files
VoiceStudio/.github/workflows/release.yml
T
85b0db65bc ci(release): version-first release titles so the tag shows in GitHub's truncated release list (#922)
GitHub's release-list sidebar clips the title mid-string, hiding the version
when it trails 'OmniVoice Studio'. Name stable releases 'vX.Y.Z — OmniVoice
Studio' and the preview 'Preview — OmniVoice Studio'. Existing releases were
renamed to match.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:24:27 +05:30

833 lines
41 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
# - schedule (nightly, 07:00 UTC) → rolling `preview` PRERELEASE built from
# `main` with its own signed `latest.json` at releases/download/preview/.
# Feeds the opt-in Preview updater channel (Settings → About → Update
# channel). The `preview-gate` job skips the matrix on nights when `main`
# didn't move, so an idle day costs only a ~30s gate job — keeping Preview
# ≤24h behind `main` at a predictable ~1-matrix/day cost. The stable
# `latest` release is untouched.
# - workflow_dispatch (publish_preview=true) → the same preview build on
# demand from the selected branch (e.g. to preview a feature branch, or to
# refresh immediately without waiting for the nightly).
# - 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*']
schedule:
# 07:00 UTC daily — rolling `preview` prerelease from `main`. The
# preview-gate job no-ops the matrix when main hasn't moved in a day.
- cron: '0 7 * * *'
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
# Decide preview-vs-stable, and for nightly runs whether `main` actually
# moved in the last day. Outputs gate the expensive matrix (`build`) and the
# `preview-notes` job, so a no-commit night costs only this ~30s job.
preview-gate:
name: Preview gate
runs-on: ubuntu-22.04
outputs:
is_preview: ${{ steps.decide.outputs.is_preview }}
proceed: ${{ steps.decide.outputs.proceed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 50
- id: decide
shell: bash
run: |
set -euo pipefail
event="${{ github.event_name }}"
if [ "$event" = "schedule" ] || { [ "$event" = "workflow_dispatch" ] && [ "${{ inputs.publish_preview }}" = "true" ]; }; then
echo "is_preview=true" >> "$GITHUB_OUTPUT"
else
echo "is_preview=false" >> "$GITHUB_OUTPUT"
fi
# Nightly: skip the matrix when main hasn't moved in the last day.
if [ "$event" = "schedule" ] && [ -z "$(git log --since='25 hours ago' --oneline)" ]; then
echo "No new commits on main in the last day — skipping nightly preview."
echo "proceed=false" >> "$GITHUB_OUTPUT"
else
echo "proceed=true" >> "$GITHUB_OUTPUT"
fi
build:
needs: [test, preview-gate]
# Nightly runs with no new commits on main skip the 4-platform matrix.
if: needs.preview-gate.outputs.proceed == 'true'
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 (#279): reinstated. The earlier "Rosetta 2 runs the
# ARM build" rationale for dropping it was backwards — Rosetta only
# translates x86_64→arm64, so Intel Macs (supported through macOS
# Sequoia) simply cannot run the aarch64 bundle and had NO
# installable artifact. Runner: `macos-15-intel`, GitHub's
# designated migration target after macos-13 retired (Dec 2025);
# it's a standard (public-repo-free) image supported through
# August 2027 — the last x86_64 image Actions will offer. Building
# natively (not cross-compiling from the arm64 leg) keeps the
# per-TRIPLE uv/ffmpeg sidecar fetches, the DMG installer smoke,
# and the ad-hoc signing verification (scripts/
# verify-macos-signing.sh, PR #290) all exercising the real
# x86_64 artifact on real Intel hardware. The macos-13 queue
# backlog that motivated the original drop is contained by
# fail-fast:false — a slow Intel leg can delay the release run but
# can't fail the other targets.
#
# #889 (2026-07): Intel macOS is now UNSUPPORTED for the local
# backend — torch ≥2.3 ships no macOS x86_64 wheels, so the venv
# bootstrap can never succeed on Intel. The shipped x64 artifact is
# effectively UI-only (usable with a remote backend); the app now
# pre-fails first-run bootstrap with an honest message on Intel.
# Whether to keep shipping this x64 leg (UI-only) or drop it is an
# OWNER CALL — deliberately not changed in the #889 PR.
- os: macos-15-intel
arch: x86_64-apple-darwin
label: "macOS Intel"
rust_target: x86_64-apple-darwin
bundles: "app,dmg,updater"
# 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 .AppImage only. AppImage is universal (no distro
# package-manager dep), runs on any glibc-2.31+ host, and is the
# Linux auto-update target. The .deb target was dropped: tauri-bundler
# fails it with "Failed to create control scripts: No such file or
# directory" (no custom deb config of ours is at fault) — revisit on a
# tauri-cli bump. FUSE unavailability on GH runners is 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: "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 — natively correct on the
# Intel leg, and runs via Rosetta 2 on the arm64 leg. Both
# darwin TRIPLEs therefore bundle the same payload; only the
# sidecar filename suffix differs.
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"
# Apple code-signing is OPT-IN and OFF by default. Export the APPLE_*
# secrets to $GITHUB_ENV ONLY for a stable `v*` release with the
# MACOS_SIGNING_ENABLED repo variable set. On every other path (preview,
# or stable without the var) the APPLE_* vars stay ABSENT — NOT empty.
# This matters: Tauri's macOS bundler runs `security import` whenever
# APPLE_CERTIFICATE is *present* (even ""), which fails the whole build;
# absence makes it skip cert import and bundle unsigned (users clear
# quarantine via `xattr -cr`, see docs/install/macos.md). A static `env:`
# on the build step can't express "absent", so signing lives here.
# To enable signed stable releases: fix the signing secrets, then set the
# repo variable MACOS_SIGNING_ENABLED = true.
- name: Configure Apple signing (stable, opt-in)
if: runner.os == 'macOS' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.MACOS_SIGNING_ENABLED == 'true'
env:
C: ${{ secrets.APPLE_CERTIFICATE }}
CP: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
SI: ${{ secrets.APPLE_SIGNING_IDENTITY }}
AID: ${{ secrets.APPLE_ID }}
AP: ${{ secrets.APPLE_PASSWORD }}
TID: ${{ secrets.APPLE_TEAM_ID }}
run: |
{
echo "APPLE_CERTIFICATE<<__OV_EOF__"
echo "$C"
echo "__OV_EOF__"
echo "APPLE_CERTIFICATE_PASSWORD=$CP"
echo "APPLE_SIGNING_IDENTITY=$SI"
echo "APPLE_ID=$AID"
echo "APPLE_PASSWORD=$AP"
echo "APPLE_TEAM_ID=$TID"
} >> "$GITHUB_ENV"
# Stamp each preview build with a unique, monotonically increasing semver
# PRERELEASE so the updater actually offers it (a rolling preview that
# always reported the static 0.3.0 never looked "newer", so no update was
# ever delivered). Ephemeral, CI-only — never committed. Tauri reads the
# bundle + updater version from tauri.conf.json, so rewriting it here
# stamps the artifacts + latest.json. Under the versioning hard rule
# (owner-set 2026-06-11) main is always last-release + 1, so BASE-N is a
# prerelease of the NEXT version and semver-sorts ABOVE the last stable
# (0.3.6-N > 0.3.5) — preview users naturally upgrade past stable, and
# the Windows MSI ProductVersion (which strips the prerelease → 0.3.6)
# is also correctly above the last stable.
- name: Stamp preview version
if: needs.preview-gate.outputs.is_preview == 'true'
shell: bash
run: |
set -euo pipefail
# package.json is the single source of truth; tauri.conf.json reads its
# version from it ("version": "../package.json"), so stamping
# package.json restamps the whole bundle.
CONF=frontend/package.json
BASE=$(jq -r .version "$CONF")
# MSI/WiX requires the semver pre-release identifier to be numeric-only
# (and <= 65535). "preview.N" hard-fails the Windows bundler, so the
# preview stamp is BASE-N — still sorts below the stable BASE for the
# updater, still unique per run.
PREVIEW_VERSION="${BASE}-${{ github.run_number }}"
tmp=$(mktemp)
jq --arg v "$PREVIEW_VERSION" '.version = $v' "$CONF" > "$tmp"
mv "$tmp" "$CONF"
echo "Stamped preview version: $PREVIEW_VERSION"
- 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 Apple signing (#134 / #72) is configured by the preceding
# "Configure Apple signing" step — it exports APPLE_* to $GITHUB_ENV
# only on the opt-in stable path, leaving them ABSENT (not "") on
# preview/unsigned paths so Tauri's bundler skips cert import. A static
# env: here would always set them to "" and break the mac build.
# Unsigned paths still get a VALID ad-hoc seal from tauri.conf.json
# (bundle.macOS.signingIdentity = "-"), so a downloaded build shows the
# GUI-bypassable "unidentified developer" prompt (right-click → Open /
# Settings → "Open Anyway") instead of the un-bypassable "damaged"
# error. On the signed path APPLE_SIGNING_IDENTITY (env) overrides the
# "-" default; once notarized, Gatekeeper accepts it with no prompt.
# 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: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
# Version-first so the tag is readable in GitHub's truncated
# release-list sidebar (which clips the title mid-string).
releaseName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'Preview — OmniVoice Studio' || format('{0} — OmniVoice Studio', github.ref_name) }}
releaseBody: ${{ steps.changelog.outputs.body }}
releaseDraft: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'false' || (inputs.draft || 'true') }}
prerelease: ${{ needs.preview-gate.outputs.is_preview == 'true' }}
updaterJsonPreferNsis: false
includeUpdaterJson: true
# ── Installer smoke (Phase 0 GATE-03) ─────────────────────────────
# Structural verification of the installed/extracted bundle. The thin
# uv-venv installer ships NO frozen backend binary (the venv is built on
# first launch via the bundled `uv`), so there is nothing to boot with
# `--health-check` here. Instead assert the bundle carries the shell
# binary, the bundled `uv` sidecar, and the backend source resources
# (pyproject.toml + backend/main.py) — the real "is the bundle complete"
# regression that ci.yml's in-process smoke can't catch.
- 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"
# Grab the full mount path — the volume name has a space ("OmniVoice
# Studio"), so `awk '{print $3}'` would truncate it to /Volumes/OmniVoice.
MOUNT=$(hdiutil attach -nobrowse -readonly "$DMG" | tail -1 | grep -oE '/Volumes/.*$')
APP=$(find "$MOUNT" -maxdepth 2 -name "*.app" | head -1)
fail() { echo "FAIL — $1"; find "$APP/Contents" -maxdepth 4 -type f 2>/dev/null | head -40; hdiutil detach "$MOUNT" || true; exit 1; }
[ -n "$APP" ] || { echo "FAIL — no .app inside DMG"; hdiutil detach "$MOUNT" || true; exit 1; }
# Thin uv-venv installer ships no frozen backend to boot — verify the
# bundle is complete: shell binary + bundled uv sidecar + backend source.
ls "$APP/Contents/MacOS"/* >/dev/null 2>&1 || fail "no shell binary in Contents/MacOS"
find "$APP/Contents" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
find "$APP/Contents" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
find "$APP/Contents" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
echo "OK — bundle has shell + uv + backend resources"
hdiutil detach "$MOUNT" || true
# ── Signing / Gatekeeper / notarization verification ──────────────
# Runs codesign --verify, spctl (Gatekeeper), nested-binary, and
# stapler checks against the built .app (see docs/macos-signing-verification.md).
# STRICT (--require-signed) only on the opt-in signed stable path — same
# condition as "Configure Apple signing" above — so a failed or missing
# signature/notarization FAILS the job and STOPS the release instead of
# publishing an unsigned artifact. On every other (unsigned dev/preview)
# path it runs report-only and never breaks the build.
- name: Verify macOS signing
if: runner.os == 'macOS'
shell: bash
env:
STRICT: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.MACOS_SIGNING_ENABLED == 'true') && '1' || '0' }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -uo pipefail
APP=$(find "frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos" -maxdepth 1 -name '*.app' | head -1)
[ -n "$APP" ] || { echo "FAIL — no .app found to verify"; exit 1; }
MODE=""
if [ "$STRICT" = "1" ]; then
MODE="--require-signed"
echo "Signed stable release → STRICT verification (release stops on failure)."
else
echo "Unsigned dev/preview path → report-only verification."
fi
bash scripts/verify-macos-signing.sh "$APP" $MODE
- 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
INSTALL="/c/Program Files/OmniVoice Studio"
fail() { echo "FAIL — $1. Contents:"; find "$INSTALL" -maxdepth 4 -type f 2>/dev/null | head -40; exit 1; }
# Thin uv-venv installer ships no frozen backend .exe — verify the
# install is complete: shell exe + bundled uv + backend source resources.
test -f "$INSTALL/omnivoice-studio.exe" || fail "shell exe missing"
test -f "$INSTALL/uv.exe" || fail "bundled uv missing"
find "$INSTALL" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
echo "OK — MSI installed shell + uv + backend resources"
- 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)
# Resolve to an absolute path BEFORE the cd below — `--appimage-extract`
# always writes ./squashfs-root into the CWD, so we cd into a temp dir,
# at which point a relative AppImage path would no longer resolve.
APPIMAGE=$(realpath "$APPIMAGE")
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
ROOT="$EXTRACT_DIR/squashfs-root"
fail() { echo "FAIL — $1"; find "$ROOT" -maxdepth 5 -type f 2>/dev/null | head -40; exit 1; }
# Thin uv-venv installer: verify the AppImage carries the shell binary,
# the bundled uv sidecar, and the backend source resources.
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "OmniVoice Studio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing"
find "$ROOT" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
find "$ROOT" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
find "$ROOT" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
echo "OK — AppImage has shell + uv + backend resources"
# ── 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).
# NB: macOS runners use /bin/bash 3.2, which has no `mapfile` (a bash 4+
# builtin) — using it 127'd this step and dropped the macOS SHA256SUMS
# for v0.3.1 and v0.3.2. A `while read` loop is portable to bash 3.2.
ARTIFACTS=()
while IFS= read -r artifact; do
ARTIFACTS+=("$artifact")
done < <(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
# ── Auto-generated preview release notes ──────────────────────────────────
# tauri-action publishes the rolling `preview` release with the plain
# changelog-fallback body ("Auto-generated release for main…"). Replace it
# with GitHub's auto-generated notes (What's Changed by PR + Contributors +
# Full Changelog) once the matrix has finished. Runs once (no matrix race),
# preview-only — stable `v*` releases keep their CHANGELOG section + the
# appended checksums.
preview-notes:
needs: [build, preview-gate]
if: needs.preview-gate.outputs.is_preview == 'true'
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- name: Generate + apply GitHub release notes to the preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
NOTES=$(gh api --method POST "repos/$REPO/releases/generate-notes" -f tag_name=preview --jq .body)
# Build a Contributors avatar strip from the PR authors GitHub listed
# in the notes ("by @handle in …"). Inline <a>/<img> render on the
# release page (GitHub strips inline styles, so avatars are square).
CONTRIB=""
HANDLES=$(printf '%s\n' "$NOTES" | grep -oE 'by @[A-Za-z0-9-]+' | sed 's/^by @//' | sort -u || true)
if [ -n "$HANDLES" ]; then
CONTRIB=$'## Contributors\n\n'
while IFS= read -r h; do
[ -z "$h" ] && continue
CONTRIB="$CONTRIB<a href=\"https://github.com/$h\" title=\"@$h\"><img src=\"https://github.com/$h.png?size=64\" width=\"48\" alt=\"@$h\"/></a> "
done <<< "$HANDLES"
fi
{
echo "> 🧪 **Rolling preview build from \`main\`** — newest features, less tested. Opt in via **Settings → About → Update channel → Preview**; switch back to Stable any time."
echo ""
echo "$NOTES"
echo ""
echo "$CONTRIB"
} > /tmp/preview-notes.md
# --prerelease re-asserts the flag every run: a non-prerelease
# `preview` release is eligible to become GitHub's "Latest", which is
# the exact URL the Stable updater channel reads — so it must never
# flip off.
gh release edit preview --repo "$REPO" --prerelease --notes-file /tmp/preview-notes.md
echo "Applied auto-generated release notes + contributors to the preview release."
- name: Verify preview updater manifest (prerelease + platform parity)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# The preview release must stay a prerelease (or it can hijack the
# Stable channel's releases/latest endpoint), and its updater manifest
# must cover every platform stable does (else those users — e.g. Intel
# Mac — silently get no preview updates).
is_pre=$(gh release view preview --repo "$REPO" --json isPrerelease -q .isPrerelease)
test "$is_pre" = "true" || { echo "::error::preview release is not a prerelease"; exit 1; }
curl -fsSL "https://github.com/$REPO/releases/download/preview/latest.json" -o /tmp/preview-latest.json
curl -fsSL "https://github.com/$REPO/releases/latest/download/latest.json" -o /tmp/stable-latest.json
python3 - <<'PY'
import json, re
prev = json.load(open("/tmp/preview-latest.json"))
stab = json.load(open("/tmp/stable-latest.json"))
v = prev.get("version", "")
assert re.fullmatch(r"\d+\.\d+\.\d+-\d+", v), f"preview version not X.Y.Z-N: {v!r}"
pk, sk = set(prev.get("platforms", {})), set(stab.get("platforms", {}))
missing = sk - pk
assert not missing, f"preview manifest missing platforms vs stable: {sorted(missing)}"
print(f"preview manifest OK: {v} platforms={sorted(pk)}")
PY
# ── Post-release version bump (OWNER-GATED as of 2026-07-01) ──────────────
# Previously auto-ran after every stable v* tag to keep main = release + 1.
# The owner now controls bumps manually ("keep 0.3.8; I say when to bump"), so
# this job is OPT-IN: it runs ONLY when the repo variable AUTO_VERSION_BUMP is
# set to 'true' (Settings → Secrets and variables → Actions → Variables).
# Unset/anything-else → main stays at whatever it is after release. Re-enable
# by setting the variable; disable again by unsetting it.
version-bump:
if: >-
github.event_name == 'push' && github.ref_type == 'tag'
&& !contains(github.ref, '-')
&& vars.AUTO_VERSION_BUMP == 'true'
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- name: Bump main to released version + 1 patch
shell: bash
run: |
set -euo pipefail
RELEASED="${GITHUB_REF_NAME#v}"
IFS=. read -r MAJ MIN PAT <<< "$RELEASED"
NEXT="$MAJ.$MIN.$((PAT + 1))"
# frontend/package.json is the SINGLE SOURCE OF TRUTH: vite injects
# __APP_VERSION__ from it, and tauri.conf.json reads its bundle version
# from it ("version": "../package.json"). Read CURRENT from it.
CURRENT=$(jq -r .version frontend/package.json)
if [ "$(printf '%s\n' "$NEXT" "$CURRENT" | sort -V | tail -1)" = "$CURRENT" ] && [ "$NEXT" != "$CURRENT" ]; then
echo "main is already at $CURRENT (>= $NEXT) — nothing to bump"; exit 0
fi
# Bump the canonical (package.json), set absolutely so any prior drift
# self-heals. tauri.conf.json needs no edit — it derives from this.
tmp=$(mktemp)
jq --arg v "$NEXT" '.version = $v' frontend/package.json > "$tmp"
mv "$tmp" frontend/package.json
# The remaining files are CI-guarded mirrors (cargo/uv require a
# literal; the version.py literal is the frozen-backend last resort) —
# bump them in lockstep with the canonical.
sed -i "0,/^version = \"$CURRENT\"/s//version = \"$NEXT\"/" frontend/src-tauri/Cargo.toml
sed -i "0,/^version = \"$CURRENT\"/s//version = \"$NEXT\"/" pyproject.toml
sed -i "0,/_FALLBACK_VERSION = \"$CURRENT\"/s//_FALLBACK_VERSION = \"$NEXT\"/" backend/core/version.py
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add frontend/package.json frontend/src-tauri/Cargo.toml pyproject.toml backend/core/version.py
git commit -m "chore(version): main -> $NEXT after $GITHUB_REF_NAME release"
git push origin main