Files
VoiceStudio/.github/workflows/release.yml
T
Palash DebnathandClaude Opus 4.8 672f106f05 feat(update): Stable/Preview update channels with opt-in toggle (#199)
Adds a user-selectable updater release channel (Settings -> About -> Update
channel). Stable (default, every install + launch) tracks tagged vX.Y.Z
releases; Preview tracks the latest main build via a rolling "preview"
prerelease, falling back to stable if a stable release is ahead.

Why Rust: tauri-plugin-updater reads its endpoints from tauri.conf.json and
neither the JS check() nor the plugin's registration Builder can change them at
runtime (verified against the 2.10.1 source). The only runtime-endpoint API is
UpdaterExt::endpoints, so check+install move into two Rust commands that mirror
the plugin's own check/download_and_install -- the Stable path behaves
identically to the JS flow it replaces; only which manifest is consulted
changes. Switching is instant (channel is read per check), no restart.

backend (Rust):
- config.rs: update_channel field (default "stable", VALID_CHANNELS) +
  get/set_update_channel commands.
- updater_channel.rs: channel_endpoints() (preview -> [preview, stable]) +
  check_update / install_update commands; install emits update://progress.

frontend:
- utils/updateChannel.js (+test): single source of truth, normalizeChannel.
- utils/updater.js: routes the badge flow (#198) through the Rust commands via
  the same store contract -- UpdateBadge/App.jsx unchanged.
- Settings About: Stable/Preview segmented toggle, channel-aware endpoint row +
  diagnostics; Check-for-updates honors the live channel.
- i18n en + zh-CN.

release.yml: additive, workflow_dispatch-guarded preview publish to a rolling
"preview" prerelease. The v* tag-push stable path evaluates to its exact prior
values (verified) and is never affected. Preview builds are manual -- no
scheduled CI spend, nothing auto-published.

docs/update-channels.md.

Verified: cargo check (compiles clean), tsc, vitest 162/162, build, CJK guard,
release.yml YAML parses.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 07:49:49 +05:30

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