The "Compute SHA-256 checksums" step used `mapfile -t` (a bash 4+ builtin) but
macOS GitHub runners execute `shell: bash` as /bin/bash 3.2, which has no
`mapfile`. The step exited 127 ("mapfile: command not found") on the macOS leg,
so `SHA256SUMS-macOS Apple Silicon.txt` was never produced/uploaded for v0.3.1
and v0.3.2 (the binaries themselves shipped fine; only the macOS checksum file
was missing and had to be regenerated by hand each time).
Replace `mapfile` with a portable `while IFS= read -r … done < <(find … | sort)`
loop (works on bash 3.2). Verified on bash 3.2.57: builds the array correctly,
handles spaces in bundle filenames. Linux/Windows legs are unaffected.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
636 lines
30 KiB
YAML
636 lines
30 KiB
YAML
# Desktop release pipeline — self-updating binaries for mac/linux/windows.
|
||
#
|
||
# Triggers:
|
||
# - push of a tag matching `v*` (e.g. `v0.2.0`) → full STABLE release,
|
||
# publishes artifacts + signed updater manifest (`latest.json`) to the
|
||
# tag's GH Release. This is the default Stable updater channel.
|
||
# - workflow_dispatch (publish_preview=true) → builds the selected branch and
|
||
# publishes a rolling `preview` PRERELEASE with its own signed
|
||
# `latest.json` at releases/download/preview/. This feeds the opt-in
|
||
# Preview updater channel (Settings → About → Update channel). The stable
|
||
# `latest` release is untouched. Run this manually whenever you want to cut
|
||
# a preview from `main`.
|
||
# - workflow_dispatch (publish_preview=false) → on-demand build (prior
|
||
# behavior; draft release named after the branch).
|
||
#
|
||
# Strategy: matrix builds per target. Each runner produces a PyInstaller
|
||
# frozen backend + Tauri bundle. `tauri-apps/tauri-action` signs the updater
|
||
# payloads with TAURI_SIGNING_PRIVATE_KEY and uploads to the GH Release for
|
||
# the tag. The built-in updater plugin polls the release's `latest.json` on
|
||
# client boot.
|
||
#
|
||
# Windows/Linux support: first-pass enabled. Expect the first few runs on
|
||
# each to surface PyInstaller/Tauri issues that never showed up locally on
|
||
# macOS — iterate on CI.
|
||
|
||
name: Desktop Release
|
||
|
||
on:
|
||
push:
|
||
tags: ['v*']
|
||
workflow_dispatch:
|
||
inputs:
|
||
draft:
|
||
description: "Create as draft release (tag push only)"
|
||
required: false
|
||
default: "true"
|
||
publish_preview:
|
||
description: "Publish a rolling 'preview' prerelease (updater Preview channel) from the selected branch"
|
||
required: false
|
||
type: boolean
|
||
default: false
|
||
|
||
permissions:
|
||
contents: write # needed to attach artifacts + updater manifest to GH Release
|
||
|
||
env:
|
||
# Run all JavaScript actions on Node 24 (GH deprecates Node 20 in Sep 2026).
|
||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||
|
||
jobs:
|
||
# Fast gating job — runs backend pytest + frontend node:test + tsc on a
|
||
# single Linux runner. The matrix build below waits on this via `needs:`
|
||
# so we don't burn 4× platform-matrix minutes on a broken commit.
|
||
test:
|
||
name: Tests (backend + frontend)
|
||
runs-on: ubuntu-22.04
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: Setup Python 3.11
|
||
uses: actions/setup-python@v5
|
||
with:
|
||
python-version: "3.11"
|
||
|
||
# enable-cache persists ~/.cache/uv keyed on uv.lock.
|
||
- name: Install uv
|
||
uses: astral-sh/setup-uv@v3
|
||
with:
|
||
enable-cache: true
|
||
cache-dependency-glob: "uv.lock"
|
||
|
||
# Node 22 is needed for --experimental-strip-types so node:test can
|
||
# import .ts files directly from frontend/src/api/*.
|
||
- name: Setup Node 22
|
||
uses: actions/setup-node@v4
|
||
with:
|
||
node-version: '22'
|
||
|
||
- name: Setup Bun
|
||
uses: oven-sh/setup-bun@v1
|
||
|
||
# Backend tests need ffmpeg (subprocess calls in fixtures). Cache the
|
||
# resolved .debs so warm runs skip the apt-get update + install.
|
||
- name: System deps (ffmpeg)
|
||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||
with:
|
||
packages: ffmpeg
|
||
version: 1.0
|
||
|
||
- name: Install Python deps
|
||
run: uv sync
|
||
|
||
- name: Run pytest
|
||
run: uv run pytest tests/ -q --tb=short
|
||
|
||
- name: Cache bun deps
|
||
uses: actions/cache@v4
|
||
with:
|
||
path: ~/.bun/install/cache
|
||
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-bun-
|
||
|
||
- name: Install frontend deps
|
||
working-directory: frontend
|
||
run: bun install
|
||
|
||
# Single-sourced typecheck command (mirrors ci.yml). The `typecheck:ci`
|
||
# script in frontend/package.json sets `--checkJs false` so pre-existing
|
||
# JS-side errors don't block the release; only .ts/.tsx files gate.
|
||
# Drift between CI and release-time typecheck flags broke v0.3.x release
|
||
# runs — keep this command identical to ci.yml's step.
|
||
- name: Frontend typecheck
|
||
working-directory: frontend
|
||
run: bun run typecheck:ci
|
||
|
||
# Invoke node directly (not `bun run test`) because `bun run` auto-aliases
|
||
# `node` to `bun` in script bodies, and bun doesn't support
|
||
# --experimental-strip-types.
|
||
- name: Run frontend node:test
|
||
working-directory: frontend
|
||
run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs
|
||
|
||
build:
|
||
needs: test
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
include:
|
||
- os: macos-14
|
||
arch: aarch64-apple-darwin
|
||
label: "macOS Apple Silicon"
|
||
rust_target: aarch64-apple-darwin
|
||
bundles: "app,dmg,updater"
|
||
|
||
# macOS Intel dropped: Apple shipped the last Intel Mac in 2023 and
|
||
# Rosetta 2 runs the ARM build natively. macos-13 runner backlog
|
||
# was also blocking every release tag for ~10 min.
|
||
# Windows: force MSI bundling via --bundles. NSIS fails at makensis
|
||
# because our PyInstaller payload approaches its ~2 GB stub limit.
|
||
- os: windows-2022
|
||
arch: x86_64-pc-windows-msvc
|
||
label: "Windows x64"
|
||
rust_target: x86_64-pc-windows-msvc
|
||
bundles: "msi,updater"
|
||
|
||
# Linux: ship .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 (runs via Rosetta on arm64).
|
||
for TOOL in ffmpeg ffprobe; do
|
||
if [ "$TOOL" = "ffmpeg" ]; then
|
||
URL="https://evermeet.cx/ffmpeg/getrelease/zip"
|
||
else
|
||
URL="https://evermeet.cx/ffmpeg/getrelease/${TOOL}/zip"
|
||
fi
|
||
echo "Fetching $TOOL from evermeet.cx"
|
||
curl -fsSL "$URL" -o "$WORK/${TOOL}.zip"
|
||
unzip -o -j "$WORK/${TOOL}.zip" -d "$WORK"
|
||
mv "$WORK/${TOOL}" "$BINDIR/${TOOL}-${TRIPLE}"
|
||
chmod +x "$BINDIR/${TOOL}-${TRIPLE}"
|
||
done
|
||
;;
|
||
x86_64-unknown-linux-gnu)
|
||
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-linux64-gpl.tar.xz"
|
||
echo "Fetching ffmpeg from BtbN (linux64) — version=${FFMPEG_BTBN_VERSION}"
|
||
curl -fsSL "$URL" -o "$WORK/ffmpeg.tar.xz"
|
||
tar -xJf "$WORK/ffmpeg.tar.xz" -C "$WORK"
|
||
# Archive extracts to ffmpeg-master-latest-linux64-gpl/bin/
|
||
EXTRACTED=$(find "$WORK" -type d -name "bin" | head -1)
|
||
mv "$EXTRACTED/ffmpeg" "$BINDIR/ffmpeg-${TRIPLE}"
|
||
mv "$EXTRACTED/ffprobe" "$BINDIR/ffprobe-${TRIPLE}"
|
||
chmod +x "$BINDIR/ffmpeg-${TRIPLE}" "$BINDIR/ffprobe-${TRIPLE}"
|
||
;;
|
||
x86_64-pc-windows-msvc)
|
||
URL="https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-win64-gpl.zip"
|
||
echo "Fetching ffmpeg from BtbN (win64) — version=${FFMPEG_BTBN_VERSION}"
|
||
curl -fsSL "$URL" -o "$WORK/ffmpeg.zip"
|
||
unzip -o "$WORK/ffmpeg.zip" -d "$WORK"
|
||
EXTRACTED=$(find "$WORK" -type f -name "ffmpeg.exe" | head -1)
|
||
EXTRACTED_DIR=$(dirname "$EXTRACTED")
|
||
mv "$EXTRACTED_DIR/ffmpeg.exe" "$BINDIR/ffmpeg-${TRIPLE}.exe"
|
||
mv "$EXTRACTED_DIR/ffprobe.exe" "$BINDIR/ffprobe-${TRIPLE}.exe"
|
||
;;
|
||
*)
|
||
echo "⚠ No ffmpeg bundling for target: $TRIPLE (will download at first run)"
|
||
;;
|
||
esac
|
||
ls -la "$BINDIR/"
|
||
|
||
|
||
# Extract the matching CHANGELOG.md section so the release body has
|
||
# real notes instead of "see commit log". Falls back to a one-liner
|
||
# if the tag has no matching `## [X.Y.Z]` section yet — keeps the
|
||
# release publishable even when CHANGELOG hasn't been updated.
|
||
- name: Extract CHANGELOG section for tag
|
||
id: changelog
|
||
shell: bash
|
||
run: |
|
||
TAG="${GITHUB_REF_NAME#v}"
|
||
BODY=""
|
||
if [ -f CHANGELOG.md ]; then
|
||
BODY=$(awk -v tag="$TAG" '
|
||
/^## \[/ {
|
||
if (in_section) exit
|
||
if ($0 ~ "\\[" tag "\\]") { in_section = 1; next }
|
||
}
|
||
in_section { print }
|
||
' CHANGELOG.md | sed -e :a -e '/^\n*$/{$d;N;ba' -e '}')
|
||
fi
|
||
if [ -z "$BODY" ]; then
|
||
BODY="Auto-generated release for ${GITHUB_REF_NAME}. See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/main/CHANGELOG.md) and the commit log for details."
|
||
fi
|
||
{
|
||
echo 'body<<RELEASE_BODY_EOF'
|
||
echo "$BODY"
|
||
echo 'RELEASE_BODY_EOF'
|
||
} >> "$GITHUB_OUTPUT"
|
||
|
||
# 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. `0.3.0-preview.N` is a prerelease of
|
||
# the current target, so previews converge to stable when 0.3.0 ships
|
||
# (0.3.0 > 0.3.0-preview.N). NOTE: the Windows MSI ProductVersion strips
|
||
# the prerelease (→ 0.3.0), a wrinkle to verify for win preview→preview
|
||
# upgrades; mac/linux replace the bundle wholesale and are unaffected.
|
||
- name: Stamp preview version
|
||
if: github.event_name == 'workflow_dispatch' && inputs.publish_preview
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
CONF=frontend/src-tauri/tauri.conf.json
|
||
BASE=$(jq -r .version "$CONF")
|
||
PREVIEW_VERSION="${BASE}-preview.${{ 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.
|
||
# GH runners disable FUSE, so linuxdeploy's AppImage can't mount
|
||
# itself at bundle time. This env tells linuxdeploy to extract-and-run
|
||
# instead, which works without FUSE.
|
||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||
with:
|
||
projectPath: frontend
|
||
args: --target ${{ matrix.rust_target }} --bundles ${{ matrix.bundles }}
|
||
# Preview path (workflow_dispatch + publish_preview=true) targets a
|
||
# rolling `preview` prerelease for the updater's Preview channel.
|
||
# Every other invocation — crucially the `v*` tag-push stable release
|
||
# — evaluates these expressions to exactly their prior values.
|
||
tagName: ${{ (github.event_name == 'workflow_dispatch' && inputs.publish_preview) && 'preview' || github.ref_name }}
|
||
releaseName: ${{ (github.event_name == 'workflow_dispatch' && inputs.publish_preview) && 'OmniVoice Studio (Preview)' || format('OmniVoice Studio {0}', github.ref_name) }}
|
||
releaseBody: ${{ steps.changelog.outputs.body }}
|
||
releaseDraft: ${{ (github.event_name == 'workflow_dispatch' && inputs.publish_preview) && 'false' || (inputs.draft || 'true') }}
|
||
prerelease: ${{ (github.event_name == 'workflow_dispatch' && inputs.publish_preview) || false }}
|
||
updaterJsonPreferNsis: false
|
||
includeUpdaterJson: true
|
||
|
||
# ── Installer smoke (Phase 0 GATE-03) ─────────────────────────────
|
||
# 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
|
||
|
||
- 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
|
||
if: github.event_name == 'workflow_dispatch' && inputs.publish_preview
|
||
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
|
||
gh release edit preview --repo "$REPO" --notes-file /tmp/preview-notes.md
|
||
echo "Applied auto-generated release notes + contributors to the preview release."
|