Renames what users see. The app, the installers, the window title, the
docs and all 21 locales now say VoiceStudio, with "(previously
OmniVoice-Studio)" noted near the title of each doc surface so people
recognise it.
Deliberately NOT renamed, because renaming any of them silently breaks
an existing install — there is no legacy-path fallback anywhere in this
codebase:
- bundle identifier com.debpalash.omnivoice-studio (MSI UpgradeCode,
macOS TCC grants, managed venv, WebView localStorage, the
single-instance lock)
- data directories OmniVoice / .omnivoice and omnivoice.db
- the ~150 OMNIVOICE_* environment variables
- the X-OmniVoice-* HTTP headers (a wire protocol)
- the published Docker image paths
- the OmniVoice ENGINE, which is a model name and not this product
tests/test_identity_paths_survive_the_rename.py pins every one of those
so a future well-meaning sweep cannot orphan a user's library.
Linux .deb users install a new package name and should apt remove
omnivoice-studio; that note is in the changelog.
1221 lines
63 KiB
YAML
1221 lines
63 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.
|
||
# - 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). Previews ALWAYS build from main — dispatching from any other branch fails the preview-gate."
|
||
required: false
|
||
type: boolean
|
||
default: false
|
||
|
||
permissions:
|
||
contents: write # needed to attach artifacts + updater manifest to GH Release
|
||
|
||
# Every preview build publishes to the SAME rolling `preview` release, and the
|
||
# updater manifest is rebuilt from whatever assets are on it. Two overlapping
|
||
# preview runs (the nightly schedule and a manual dispatch, say) would upload
|
||
# into each other's asset set, and the version-less macOS tarballs carry
|
||
# nothing saying which run produced them — so one run could publish a manifest
|
||
# advertising its own version while serving the other run's macOS binaries
|
||
# (greptile). Serialize instead. Keyed on the ref, so a `v*` tag push (which
|
||
# builds its own release and never touches `preview`) is never queued behind a
|
||
# nightly.
|
||
concurrency:
|
||
group: desktop-release-${{ github.ref }}
|
||
cancel-in-progress: false
|
||
|
||
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
|
||
# Preview channel policy (owner-set 2026-07-16): previews ALWAYS
|
||
# build from main. The preview updater manifest and the Docker
|
||
# rolling tags (:latest/:main/:rocm) all track main — a preview
|
||
# cut from a side branch would desync the channels and could
|
||
# ship code that never merged. Merge to main first.
|
||
if [ "${{ github.ref }}" != "refs/heads/main" ]; then
|
||
echo "::error::Preview builds publish from main only (got '${{ github.ref }}'). Merge to main, then dispatch with publish_preview=true."
|
||
exit 1
|
||
fi
|
||
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.39+ 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.
|
||
#
|
||
# Bumped from ubuntu-22.04 → ubuntu-24.04 (#961): the AppImage
|
||
# bundles whatever `libwebkit2gtk-4.1-dev` the build runner's apt
|
||
# repos resolve (see the "Linux system deps" step below) — 22.04's
|
||
# was meaningfully stale relative to what current Ubuntu/Fedora
|
||
# ship, and AppRun's LD_LIBRARY_PATH makes that bundled, stale copy
|
||
# take priority over a healthy system WebKitGTK at runtime. Raises
|
||
# the AppImage's glibc floor from 2.35 to 2.39 — pre-2022 distros
|
||
# (Ubuntu <22.04, Debian <12) lose support; no report of anyone on
|
||
# something that old has come in, and the project's own install
|
||
# docs already assume Debian 12 / Ubuntu 22.04+.
|
||
- os: ubuntu-24.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"
|
||
|
||
# The rolling `preview` release is REUSED every night, and macOS updater
|
||
# artifacts are the only ones Tauri names WITHOUT the version:
|
||
#
|
||
# VoiceStudio_0.4.1-103_x64.dmg <- unique per run, uploads fine
|
||
# VoiceStudio_x64.app.tar.gz <- constant, collides
|
||
#
|
||
# So every preview build after the first failed the macOS legs with
|
||
# `Validation Failed: {"resource":"ReleaseAsset","code":"already_exists"}`
|
||
# — and it failed AFTER the dmg upload, so the run went red while looking
|
||
# partially successful. The macOS updater bundles on `preview` went stale
|
||
# on 2026-07-04/05 and stayed that way for three weeks: Preview-channel
|
||
# macOS users had no working update path, and the nightly run was red
|
||
# every night.
|
||
#
|
||
# Delete this arch's updater bundle before uploading the new one. Scoped
|
||
# to the preview path (a `v*` tag makes a fresh release, nothing to
|
||
# collide with) and to this job's own arch, so the parallel aarch64/x64
|
||
# legs never touch each other's assets.
|
||
#
|
||
# ONLY an absent release/asset is benign. Auth, permission, rate-limit and
|
||
# network failures must not be swallowed: the step would report success
|
||
# while the stale asset survived, the upload would then die with
|
||
# `already_exists`, and we would be back to the exact outage this step
|
||
# exists to prevent — minus the red step that explains why. Since GH_TOKEN
|
||
# is scoped to this same repo, a 404 really does mean "not there".
|
||
- name: Clear this arch's stale preview updater bundle (macOS)
|
||
if: needs.preview-gate.outputs.is_preview == 'true' && runner.os == 'macOS'
|
||
shell: bash
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
run: |
|
||
set -uo pipefail
|
||
# aarch64-apple-darwin -> aarch64 ; x86_64-apple-darwin -> x64
|
||
case "${{ matrix.arch }}" in
|
||
aarch64-*) SUFFIX=aarch64 ;;
|
||
x86_64-*) SUFFIX=x64 ;;
|
||
*) echo "::error::unexpected arch ${{ matrix.arch }}"; exit 1 ;;
|
||
esac
|
||
# Match the STORED name, not the uploaded one. GitHub rewrites
|
||
# spaces to dots, which is why the pre-rename product ("OmniVoice
|
||
# Studio") was stored as "OmniVoice.Studio_x64.app.tar.gz".
|
||
# "VoiceStudio" has no space and so needs no translation — the
|
||
# pattern below matches both, so a preview release still holding
|
||
# pre-rename assets is still cleaned up.
|
||
if ! gh release view preview --json assets -q '.assets[].name' \
|
||
> /tmp/preview-assets.txt 2> /tmp/gh-view-err.txt; then
|
||
if grep -qiE 'not found|HTTP 404' /tmp/gh-view-err.txt; then
|
||
echo "No preview release yet — nothing to clear."
|
||
exit 0
|
||
fi
|
||
echo "::error::Could not read the preview release, so a stale ${SUFFIX} bundle may still be there."
|
||
echo "Refusing to continue blind — the Tauri upload would fail with already_exists."
|
||
cat /tmp/gh-view-err.txt
|
||
exit 1
|
||
fi
|
||
grep -E "(^VoiceStudio|[ .]Studio)_${SUFFIX}\.app\.tar\.gz(\.sig)?$" /tmp/preview-assets.txt \
|
||
> /tmp/stale.txt || true
|
||
if [ ! -s /tmp/stale.txt ]; then
|
||
echo "No stale ${SUFFIX} updater bundle on preview — nothing to clear."
|
||
exit 0
|
||
fi
|
||
while IFS= read -r name; do
|
||
echo "Removing stale preview asset: $name"
|
||
if ! gh release delete-asset preview "$name" --yes \
|
||
2> /tmp/gh-del-err.txt; then
|
||
# Already gone is fine — a re-run or the sibling leg beat us to
|
||
# it, and the goal (no asset under this name) is met either way.
|
||
if grep -qiE 'not found|HTTP 404' /tmp/gh-del-err.txt; then
|
||
echo " (already gone — nothing to collide with)"
|
||
continue
|
||
fi
|
||
echo "::error::Failed to delete stale preview asset $name."
|
||
cat /tmp/gh-del-err.txt
|
||
exit 1
|
||
fi
|
||
done < /tmp/stale.txt
|
||
|
||
- name: Build + release (Tauri)
|
||
uses: tauri-apps/tauri-action@v0
|
||
env:
|
||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
# Analytics destination, injected at BUILD time (never committed — a
|
||
# token-shaped literal in the repo trips the secret scanner, and the
|
||
# frontend bundle is where a publishable client key belongs). Absent =>
|
||
# the build has no destination, the Privacy toggle isn't offered, and
|
||
# nothing can be sent. Analytics still requires the user to opt in.
|
||
VITE_POSTHOG_KEY: ${{ secrets.POSTHOG_PROJECT_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 — VoiceStudio' || format('{0} — VoiceStudio', 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 with a grep rather than `awk '{print $3}'`.
|
||
# The volume name used to contain a space ("OmniVoice Studio"), which
|
||
# awk truncated to /Volumes/OmniVoice; "VoiceStudio" has no space, so
|
||
# this is now belt-and-braces rather than load-bearing.
|
||
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/VoiceStudio"
|
||
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 "VoiceStudio" -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
|
||
|
||
# ── Uninstall scripts as release assets (#1089) ───────────────────────────
|
||
# The in-app uninstaller (Settings → Storage → Remove all data) is the primary
|
||
# path, but a user who wants to clean up WITHOUT launching the app — or after
|
||
# already deleting it — has no repo to run scripts/uninstall.sh from. Ship the
|
||
# two scripts alongside the installers so they're one download away.
|
||
#
|
||
# MUST use `gh release upload` (attach to the EXISTING release), NOT
|
||
# softprops/action-gh-release: a second softprops publish races tauri-action's
|
||
# per-matrix draft and splits the platform installers across two releases for
|
||
# the tag (v0.3.20 shipped with only the Linux AppImage that way). `needs:
|
||
# [build]` guarantees the release already exists; `--clobber` makes a re-run
|
||
# idempotent. This can never create a second release.
|
||
uninstall-scripts:
|
||
needs: [build]
|
||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||
runs-on: ubuntu-22.04
|
||
permissions:
|
||
contents: write
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
- name: Attach uninstall scripts to the existing release
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
run: |
|
||
gh release upload "${{ github.ref_name }}" \
|
||
scripts/uninstall.sh scripts/uninstall.ps1 \
|
||
--clobber --repo "${{ github.repository }}"
|
||
|
||
# ── Contributors avatar strip on STABLE releases ──────────────────────────
|
||
# Stable v* releases keep their curated CHANGELOG body + the per-platform
|
||
# checksums; this appends ONE "## Contributors" avatar strip crediting every
|
||
# PR author for the tag — including the owner — the courtesy the preview
|
||
# channel already gets (preview-notes job). It closes the gap where stable
|
||
# releases credited nobody.
|
||
#
|
||
# Two things the naive version got wrong (fixed here):
|
||
# 1. RANK by contribution. Authors are ordered by merged-PR count for the
|
||
# tag (descending, ties broken by handle), not alphabetically — the
|
||
# owner with 30+ PRs should not sort under a one-PR contributor.
|
||
# 2. Exactly ONE section. GitHub auto-renders its OWN "Contributors" widget
|
||
# from any plain `@handle` TEXT mention in the body (the CHANGELOG's
|
||
# "— thanks @user!" credits → `mentions_count`), which duplicates ours
|
||
# and can't be ranked or include the owner. We neutralise those inline
|
||
# text mentions in the RELEASE body only (`thanks @u` → `thanks u`; the
|
||
# repo CHANGELOG keeps the @handles) so GitHub renders no native widget —
|
||
# our ranked strip's @handles live in HTML attributes, which GitHub does
|
||
# not count as mentions, so the linked avatars stay clickable.
|
||
#
|
||
# MUST append via `gh release edit` on the EXISTING release (never a second
|
||
# softprops publish — that races tauri-action's per-matrix draft and splits
|
||
# installers across two releases; see uninstall-scripts). `needs: [build]`
|
||
# guarantees the release + all checksum appends already landed, and this job
|
||
# is single (no matrix) so there is no write race. Idempotent: it strips any
|
||
# prior "## Contributors" block before re-appending, so re-runs don't stack.
|
||
contributors-strip:
|
||
needs: [build]
|
||
if: >-
|
||
github.event_name == 'push'
|
||
&& startsWith(github.ref, 'refs/tags/v')
|
||
&& !contains(github.ref, '-')
|
||
runs-on: ubuntu-22.04
|
||
permissions:
|
||
contents: write
|
||
steps:
|
||
- name: Append ranked Contributors avatar strip to the stable release
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
REPO: ${{ github.repository }}
|
||
TAG: ${{ github.ref_name }}
|
||
run: |
|
||
set -euo pipefail
|
||
NOTES=$(gh api --method POST "repos/$REPO/releases/generate-notes" -f tag_name="$TAG" --jq .body)
|
||
# Rank PR authors by merged-PR count (desc), ties broken by handle.
|
||
RANKED=$(printf '%s\n' "$NOTES" | grep -oE 'by @[A-Za-z0-9-]+' | sed 's/^by @//' \
|
||
| sort | uniq -c | sort -k1,1nr -k2,2 | awk '{print $2}' || true)
|
||
if [ -z "$RANKED" ]; then
|
||
echo "No PR-author handles in the generated notes for $TAG — nothing to append."
|
||
exit 0
|
||
fi
|
||
# Current body: drop any prior Contributors block (idempotent re-runs;
|
||
# the strip is always the tail, and checksum sections use '### '
|
||
# headers so they never match), then neutralise inline @thanks so
|
||
# GitHub renders no duplicate native contributors widget.
|
||
BODY=$(gh release view "$TAG" --repo "$REPO" --json body --jq .body)
|
||
BODY=$(printf '%s\n' "$BODY" | sed '/^## Contributors$/,$d' | sed 's/thanks @/thanks /g')
|
||
{
|
||
printf '%s' "$BODY"
|
||
printf '\n## Contributors\n\nThank you all 💜\n\n'
|
||
while IFS= read -r h; do
|
||
[ -z "$h" ] && continue
|
||
printf '<a href="https://github.com/%s" title="@%s"><img src="https://github.com/%s.png?size=64" width="48" alt="@%s"/></a> ' "$h" "$h" "$h" "$h"
|
||
done <<< "$RANKED"
|
||
printf '\n'
|
||
} > /tmp/stable-notes.md
|
||
gh release edit "$TAG" --repo "$REPO" --notes-file /tmp/stable-notes.md
|
||
echo "Appended ranked Contributors strip ($(printf '%s' "$RANKED" | tr '\n' ' ')) to $TAG."
|
||
|
||
# ── 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
|
||
# The manifest rebuild reads this run's `created_at` from the Actions
|
||
# Runs API to tie the version-less macOS bundles to this build. Without
|
||
# this scope the call 403s and, under `set -e`, takes the whole publish
|
||
# down (greptile).
|
||
actions: read
|
||
steps:
|
||
# Needed by the manifest rebuild + signature check below: the updater
|
||
# pubkey lives in frontend/src-tauri/tauri.conf.json.
|
||
#
|
||
# persist-credentials: false — nothing in this job pushes to git, and the
|
||
# steps that follow shell out to `gh` and install from PyPI, so leaving a
|
||
# token in .git/config only widens the blast radius (CodeRabbit).
|
||
- uses: actions/checkout@v4
|
||
with:
|
||
persist-credentials: false
|
||
|
||
# ── Rebuild the preview updater manifest from what is ACTUALLY published ──
|
||
# Since ~2026-07-13 every matrix leg has logged "Signature not found for
|
||
# the updater JSON. Skipping upload..." — tauri-action uploads the bundles
|
||
# + .sig companions but never refreshes latest.json. Meanwhile the macOS
|
||
# updater bundles (version-less filenames) are deleted + replaced every
|
||
# night by "Clear this arch's stale preview updater bundle", so the
|
||
# manifest's darwin signatures stopped matching the published files:
|
||
# macOS Preview users hit "The signature verification failed" on every
|
||
# update attempt (latest.json frozen at 2026-07-13, tar.gz replaced
|
||
# nightly).
|
||
#
|
||
# Root fix: after the matrix completes, rebuild latest.json HERE — one
|
||
# job, no per-leg race — from the release's real assets and their .sig
|
||
# companions, then clobber-upload. The manifest can no longer drift from
|
||
# the files it describes, regardless of what tauri-action's own
|
||
# updater-JSON path does or skips.
|
||
- name: Rebuild + verify the preview updater manifest, then publish
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
REPO: ${{ github.repository }}
|
||
run: |
|
||
set -euo pipefail
|
||
# Floor-pinned, matching docs-drift.yml's `pyyaml>=6`: this step
|
||
# decides whether a signed release manifest is trustworthy, so it is
|
||
# the one dependency worth a bound. Only Ed25519 verify is used.
|
||
pip install --quiet "cryptography>=42"
|
||
|
||
# name + updatedAt: the timestamp is how the version-less darwin
|
||
# tarballs get tied to this run — see scripts/build_preview_manifest.py.
|
||
gh release view preview --repo "$REPO" --json assets \
|
||
-q '[.assets[] | {name, updatedAt}]' > /tmp/assets.json
|
||
|
||
# The anchor for "this upload belongs to this run" is the moment this
|
||
# run's FIRST JOB began executing. Two wrong answers were considered:
|
||
#
|
||
# * `run_started_at` RESETS on re-run — re-running just this job
|
||
# would judge the macOS bundles its own earlier attempt uploaded
|
||
# as stale, and refuse a healthy build.
|
||
# * the run's `created_at` is stamped when the run is QUEUED. With
|
||
# the concurrency group above, a run can sit queued while the
|
||
# previous one uploads — so the queued run's created_at predates
|
||
# the OTHER run's macOS bundles and would accept them as its own
|
||
# (coderabbit).
|
||
#
|
||
# The earliest job start is after the queue wait (concurrency holds
|
||
# the whole run, so no job of ours has started) and before any of our
|
||
# own uploads. Jobs that were not re-run keep their original
|
||
# timestamps, so taking the MINIMUM stays correct across partial
|
||
# re-runs too.
|
||
#
|
||
# Needs the job's `actions: read` scope. If it ever 403s anyway, do
|
||
# not take the whole publish down with `set -e`: warn loudly and let
|
||
# build_manifest fall back to its leg-to-leg comparison, which is
|
||
# merely stricter than it should be, never laxer.
|
||
if ! RUN_CREATED_AT=$(gh api --paginate \
|
||
"repos/$REPO/actions/runs/${{ github.run_id }}/jobs?filter=latest" \
|
||
--jq '[.jobs[].started_at] | map(select(. != null)) | min // empty' \
|
||
2> /tmp/gh-run-err.txt); then
|
||
echo "::warning::Could not read this run's job start times (needs actions: read) — falling back to the stricter sibling-timestamp check, which can refuse a healthy build."
|
||
cat /tmp/gh-run-err.txt
|
||
RUN_CREATED_AT=""
|
||
fi
|
||
echo "This run began executing at ${RUN_CREATED_AT:-<unknown>}"
|
||
export RUN_CREATED_AT
|
||
|
||
WORK=$(mktemp -d)
|
||
python3 - "$WORK" <<'PY'
|
||
import base64, hashlib, json, os, subprocess, sys
|
||
from urllib.parse import unquote
|
||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||
|
||
sys.path.insert(0, "scripts")
|
||
from build_preview_manifest import ManifestRefused, build_manifest, required_assets
|
||
|
||
work, repo = sys.argv[1], os.environ["REPO"]
|
||
assets = json.load(open("/tmp/assets.json"))
|
||
|
||
def fetch(pattern):
|
||
subprocess.run(["gh", "release", "download", "preview", "--repo", repo,
|
||
"-p", pattern, "-D", work], check=True)
|
||
|
||
signatures = {}
|
||
for name in required_assets(assets):
|
||
fetch(name + ".sig")
|
||
signatures[name] = open(os.path.join(work, name + ".sig")).read()
|
||
|
||
try:
|
||
manifest = build_manifest(
|
||
assets, repo, signatures=signatures,
|
||
run_started_at=os.environ.get("RUN_CREATED_AT") or None,
|
||
)
|
||
except ManifestRefused as e:
|
||
sys.exit(f"Refusing to publish a preview manifest: {e}")
|
||
print(f"Built preview latest.json: version={manifest['version']}")
|
||
|
||
# ── Verify BEFORE publishing ──────────────────────────────────────
|
||
# Order is the whole point (greptile). Uploading first and checking
|
||
# afterwards leaves a manifest that fails the check live and served:
|
||
# the job goes red, and every macOS Preview user is broken until
|
||
# someone notices. Verify the file we are about to publish.
|
||
conf = json.load(open("frontend/src-tauri/tauri.conf.json"))
|
||
pub_doc = base64.b64decode(conf["plugins"]["updater"]["pubkey"]).decode()
|
||
pub = base64.b64decode(pub_doc.strip().splitlines()[1])
|
||
assert pub[:2] == b"Ed", "unexpected pubkey algorithm"
|
||
pk = Ed25519PublicKey.from_public_bytes(pub[10:42])
|
||
|
||
digests, failures = {}, []
|
||
for plat, info in sorted(manifest["platforms"].items()):
|
||
name = unquote(info["url"].rsplit("/", 1)[-1])
|
||
path = os.path.join(work, name)
|
||
if name not in digests:
|
||
fetch(name)
|
||
h = hashlib.blake2b(digest_size=64)
|
||
with open(path, "rb") as f:
|
||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||
h.update(chunk)
|
||
digests[name] = h.digest()
|
||
lines = base64.b64decode(info["signature"]).decode().splitlines()
|
||
sig = base64.b64decode(lines[1])
|
||
tc = lines[2].split("trusted comment: ", 1)[1]
|
||
gsig = base64.b64decode(lines[3])
|
||
try:
|
||
pk.verify(sig[10:74], digests[name])
|
||
pk.verify(gsig, sig[10:74] + tc.encode())
|
||
print(f"OK {plat}: signature matches {name}")
|
||
except Exception:
|
||
failures.append(plat)
|
||
print(f"FAIL {plat}: signature does NOT match {name}")
|
||
if failures:
|
||
sys.exit("Refusing to publish: manifest is broken for "
|
||
+ ", ".join(failures)
|
||
+ ". The previously published manifest is left in place.")
|
||
|
||
json.dump(manifest, open(os.path.join(work, "latest.json"), "w"), indent=2)
|
||
print("All signatures verified — safe to publish.")
|
||
PY
|
||
|
||
gh release upload preview "$WORK/latest.json" --clobber --repo "$REPO"
|
||
echo "Uploaded verified latest.json to the preview release."
|
||
|
||
- 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 the published preview manifest (prerelease + parity + served bytes)
|
||
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
|
||
# The signatures were verified BEFORE publishing (see the rebuild
|
||
# step). What is worth checking here is different: that the file
|
||
# actually being SERVED is the one that passed. A CDN or a partial
|
||
# upload can leave something else at that URL, and the whole class of
|
||
# bug this job addresses is "the manifest does not describe what is
|
||
# published".
|
||
python3 - <<'PY'
|
||
import hashlib, json, sys
|
||
served = open("/tmp/preview-latest.json", "rb").read()
|
||
m = json.loads(served)
|
||
plats = sorted(m.get("platforms", {}))
|
||
print(f"served manifest: version={m.get('version')} platforms={plats}")
|
||
print(f"served sha256={hashlib.sha256(served).hexdigest()}")
|
||
missing = [p for p in plats if not m["platforms"][p].get("signature")]
|
||
if missing:
|
||
sys.exit(f"served manifest has empty signatures for: {missing}")
|
||
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
|