* fix(engines): show the under-provisioned-VRAM warning instead of discarding it Four of the open low-VRAM reports (#1240, #1246, #1248 on 4 GB cards; #1277 on 6 GB) share one shape: the user generates, waits out the entire 300s compute budget, and is then told the job "was too heavy for the available compute". The warning existed the whole time. Routing computes it (#1226's `_caveat`: "…has 4.0 GB VRAM; this engine wants about 6 GB. It will run, but expect slow generations that may time out"), and `/engines/select` echoes it in `routing_reason` — but notifyEngineSelected only surfaced a reason when `routing_status === 'cpu_fallback'`. The VRAM caveat rides on an ACCELERATED verdict, so it fell through to the green "switched" success toast and was thrown away. The user was told everything was fine, then waited five minutes to find out it wasn't. Now any caveat on the echo raises a warn-tone toast naming it, with a longer duration since it lists the ways around the limit. This covers the kernel-risk caveat on the same path. Deliberately still ADVISORY, not blocking — matching the routing layer's documented contract (the driver can page to system RAM, and short inputs fit where long ones don't). The engine is still selected; the user just finds out now instead of after the timeout. This is the first-run path too: the wizard's library step shares notifyEngineSelected. Fail-before verified: both new tests fail against the previous version. Known remaining gap: a user whose engine is already selected sees this only when they re-pick. A generate-time preflight would close that, but it needs a "once per session, not per generate" design — filed as follow-up rather than guessed at here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci(release): stop the macOS preview updater bundle colliding with itself The nightly preview run has failed on both macOS legs since early July: Uploading OmniVoice Studio_x64.app.tar.gz... ##[error]Validation Failed: {"resource":"ReleaseAsset", "code":"already_exists","field":"name"} `preview` is a ROLLING release, reused every night, and macOS updater artifacts are the only ones Tauri names without the version: OmniVoice Studio_0.4.1-103_x64.dmg unique per run — uploads fine OmniVoice Studio_x64.app.tar.gz constant — collides on run 2+ Consequences, verified against the live release: the macOS updater bundles on `preview` were last written 2026-07-04 (x64) and 2026-07-05 (aarch64), and latest.json 2026-07-13 — three weeks stale as of today. Preview-channel macOS users had no working update path. The failure also lands AFTER the dmg upload, so each run looked partly successful while going red. Deletes this arch's updater bundle before the upload. Matches the STORED asset name by querying the release rather than guessing the spelling — GitHub rewrites spaces to dots, so "OmniVoice Studio_x64.app.tar.gz" is stored as "OmniVoice.Studio_x64.app.tar.gz" and a literal delete-asset by the uploaded name would silently no-op. Scoped to the preview path (a v* tag creates a fresh release with nothing to collide with) and to the job's own arch, so the parallel aarch64/x64 legs can't touch each other's assets. Verified the filter against all 209 live preview assets: it matches exactly the 4 colliding updater files and no versioned artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci(release): fail loud when the preview asset sweep can't do its job The cleanup step treated every `gh` failure as "nothing to clear" — 401, 403, 429 and network errors included. That reintroduces the outage it was written to fix, with the evidence removed: the stale bundle survives, the Tauri upload dies with `already_exists`, and the one step that could have explained why is green. Three weeks of broken macOS Preview updates started exactly this way. Only an absent release/asset is benign now. A 404 on view means "no preview release yet" (GH_TOKEN is scoped to this repo, so 404 really is absence); a 404 on delete means someone already removed it, which satisfies the goal. Every other failure fails the step with the reason printed. An unexpected arch is also fatal rather than a silent skip — same class of blind spot. Adds tests/test_release_preview_asset_cleanup.py, which extracts this step's real shell body from release.yml (so it cannot drift) and runs it against a stubbed `gh`: 6 of the 8 cases fail against the previous version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1031 lines
52 KiB
YAML
1031 lines
52 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
|
||
|
||
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:
|
||
#
|
||
# OmniVoice Studio_0.4.1-103_x64.dmg <- unique per run, uploads fine
|
||
# OmniVoice Studio_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, so "OmniVoice Studio_x64.app.tar.gz" is stored as
|
||
# "OmniVoice.Studio_x64.app.tar.gz". Query the release and filter,
|
||
# rather than guessing which spelling to pass.
|
||
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 "[ .]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 — OmniVoice Studio' || format('{0} — OmniVoice Studio', github.ref_name) }}
|
||
releaseBody: ${{ steps.changelog.outputs.body }}
|
||
releaseDraft: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'false' || (inputs.draft || 'true') }}
|
||
prerelease: ${{ needs.preview-gate.outputs.is_preview == 'true' }}
|
||
updaterJsonPreferNsis: false
|
||
includeUpdaterJson: true
|
||
|
||
# ── Installer smoke (Phase 0 GATE-03) ─────────────────────────────
|
||
# Structural verification of the installed/extracted bundle. The thin
|
||
# uv-venv installer ships NO frozen backend binary (the venv is built on
|
||
# first launch via the bundled `uv`), so there is nothing to boot with
|
||
# `--health-check` here. Instead assert the bundle carries the shell
|
||
# binary, the bundled `uv` sidecar, and the backend source resources
|
||
# (pyproject.toml + backend/main.py) — the real "is the bundle complete"
|
||
# regression that ci.yml's in-process smoke can't catch.
|
||
- name: Installer smoke (macOS)
|
||
if: runner.os == 'macOS'
|
||
timeout-minutes: 5
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
DMG=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg -name "*.dmg" | head -1)
|
||
echo "Smoke-testing DMG: $DMG"
|
||
# Grab the full mount path — the volume name has a space ("OmniVoice
|
||
# Studio"), so `awk '{print $3}'` would truncate it to /Volumes/OmniVoice.
|
||
MOUNT=$(hdiutil attach -nobrowse -readonly "$DMG" | tail -1 | grep -oE '/Volumes/.*$')
|
||
APP=$(find "$MOUNT" -maxdepth 2 -name "*.app" | head -1)
|
||
fail() { echo "FAIL — $1"; find "$APP/Contents" -maxdepth 4 -type f 2>/dev/null | head -40; hdiutil detach "$MOUNT" || true; exit 1; }
|
||
[ -n "$APP" ] || { echo "FAIL — no .app inside DMG"; hdiutil detach "$MOUNT" || true; exit 1; }
|
||
# Thin uv-venv installer ships no frozen backend to boot — verify the
|
||
# bundle is complete: shell binary + bundled uv sidecar + backend source.
|
||
ls "$APP/Contents/MacOS"/* >/dev/null 2>&1 || fail "no shell binary in Contents/MacOS"
|
||
find "$APP/Contents" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
|
||
find "$APP/Contents" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
|
||
find "$APP/Contents" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
|
||
echo "OK — bundle has shell + uv + backend resources"
|
||
hdiutil detach "$MOUNT" || true
|
||
|
||
# ── Signing / Gatekeeper / notarization verification ──────────────
|
||
# Runs codesign --verify, spctl (Gatekeeper), nested-binary, and
|
||
# stapler checks against the built .app (see docs/macos-signing-verification.md).
|
||
# STRICT (--require-signed) only on the opt-in signed stable path — same
|
||
# condition as "Configure Apple signing" above — so a failed or missing
|
||
# signature/notarization FAILS the job and STOPS the release instead of
|
||
# publishing an unsigned artifact. On every other (unsigned dev/preview)
|
||
# path it runs report-only and never breaks the build.
|
||
- name: Verify macOS signing
|
||
if: runner.os == 'macOS'
|
||
shell: bash
|
||
env:
|
||
STRICT: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.MACOS_SIGNING_ENABLED == 'true') && '1' || '0' }}
|
||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||
run: |
|
||
set -uo pipefail
|
||
APP=$(find "frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos" -maxdepth 1 -name '*.app' | head -1)
|
||
[ -n "$APP" ] || { echo "FAIL — no .app found to verify"; exit 1; }
|
||
MODE=""
|
||
if [ "$STRICT" = "1" ]; then
|
||
MODE="--require-signed"
|
||
echo "Signed stable release → STRICT verification (release stops on failure)."
|
||
else
|
||
echo "Unsigned dev/preview path → report-only verification."
|
||
fi
|
||
bash scripts/verify-macos-signing.sh "$APP" $MODE
|
||
|
||
- name: Installer smoke (Windows)
|
||
if: runner.os == 'Windows'
|
||
timeout-minutes: 5
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" | head -1)
|
||
echo "Smoke-testing MSI: $MSI"
|
||
# /quiet = no UI, /norestart = don't reboot the runner if a dep asks
|
||
msiexec.exe //i "$(cygpath -w "$MSI")" //quiet //norestart
|
||
INSTALL="/c/Program Files/OmniVoice Studio"
|
||
fail() { echo "FAIL — $1. Contents:"; find "$INSTALL" -maxdepth 4 -type f 2>/dev/null | head -40; exit 1; }
|
||
# Thin uv-venv installer ships no frozen backend .exe — verify the
|
||
# install is complete: shell exe + bundled uv + backend source resources.
|
||
test -f "$INSTALL/omnivoice-studio.exe" || fail "shell exe missing"
|
||
test -f "$INSTALL/uv.exe" || fail "bundled uv missing"
|
||
find "$INSTALL" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
|
||
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
|
||
echo "OK — MSI installed shell + uv + backend resources"
|
||
|
||
- name: Installer smoke (Linux)
|
||
if: runner.os == 'Linux'
|
||
timeout-minutes: 5
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
# Use the AppImage — single-file, no installer needed.
|
||
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
|
||
# Resolve to an absolute path BEFORE the cd below — `--appimage-extract`
|
||
# always writes ./squashfs-root into the CWD, so we cd into a temp dir,
|
||
# at which point a relative AppImage path would no longer resolve.
|
||
APPIMAGE=$(realpath "$APPIMAGE")
|
||
echo "Smoke-testing AppImage: $APPIMAGE"
|
||
chmod +x "$APPIMAGE"
|
||
# GH runners have no FUSE — extract before running (mirrors APPIMAGE_EXTRACT_AND_RUN=1 used at build time).
|
||
EXTRACT_DIR="$(mktemp -d)"
|
||
cd "$EXTRACT_DIR"
|
||
"$APPIMAGE" --appimage-extract >/dev/null
|
||
ROOT="$EXTRACT_DIR/squashfs-root"
|
||
fail() { echo "FAIL — $1"; find "$ROOT" -maxdepth 5 -type f 2>/dev/null | head -40; exit 1; }
|
||
# Thin uv-venv installer: verify the AppImage carries the shell binary,
|
||
# the bundled uv sidecar, and the backend source resources.
|
||
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "OmniVoice Studio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing"
|
||
find "$ROOT" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
|
||
find "$ROOT" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
|
||
find "$ROOT" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
|
||
echo "OK — AppImage has shell + uv + backend resources"
|
||
|
||
# ── Compute SHA-256 checksums (Phase 0 GATE-05) ───────────────────
|
||
# Native OS tools: shasum -a 256 (POSIX) / Get-FileHash (Windows).
|
||
# Writes SHA256SUMS-<label>.txt for the user-verifiable path AND
|
||
# captures the content into $GITHUB_OUTPUT for body append.
|
||
- name: Compute SHA-256 checksums
|
||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||
id: checksums
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
BUNDLE_DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle"
|
||
OUT="SHA256SUMS-${{ matrix.label }}.txt"
|
||
|
||
# Gather artifact paths per matrix leg's `bundles` (msi/app/dmg/deb/appimage/updater).
|
||
# `find` is portable across all three runners (Git Bash on Windows).
|
||
# NB: macOS runners use /bin/bash 3.2, which has no `mapfile` (a bash 4+
|
||
# builtin) — using it 127'd this step and dropped the macOS SHA256SUMS
|
||
# for v0.3.1 and v0.3.2. A `while read` loop is portable to bash 3.2.
|
||
ARTIFACTS=()
|
||
while IFS= read -r artifact; do
|
||
ARTIFACTS+=("$artifact")
|
||
done < <(find "$BUNDLE_DIR" -type f \
|
||
\( -name "*.dmg" -o -name "*.app.tar.gz" -o -name "*.app.tar.gz.sig" \
|
||
-o -name "*.msi" -o -name "*.msi.sig" \
|
||
-o -name "*.AppImage" -o -name "*.AppImage.sig" \
|
||
-o -name "*.deb" \) 2>/dev/null | sort)
|
||
|
||
if [ ${#ARTIFACTS[@]} -eq 0 ]; then
|
||
echo "FAIL — no artifacts found under $BUNDLE_DIR"
|
||
find "$BUNDLE_DIR" -type f | head -50
|
||
exit 1
|
||
fi
|
||
|
||
# shasum is available on macOS by default, on ubuntu-22.04 (perl pkg),
|
||
# and on windows-2022 Git Bash. Falls back to sha256sum on Linux.
|
||
if command -v shasum >/dev/null 2>&1; then
|
||
HASHER="shasum -a 256"
|
||
else
|
||
HASHER="sha256sum"
|
||
fi
|
||
|
||
{
|
||
echo "### ${{ matrix.label }} artifacts"
|
||
echo ""
|
||
echo '```'
|
||
for f in "${ARTIFACTS[@]}"; do
|
||
# Strip the long bundle prefix from the printed path for readability;
|
||
# the hash itself is computed against the full file.
|
||
( cd "$(dirname "$f")" && $HASHER "$(basename "$f")" )
|
||
done
|
||
echo '```'
|
||
echo ""
|
||
} | tee "$OUT"
|
||
|
||
echo "checksums_file=$OUT" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Append checksums to release + attach SHA256SUMS file
|
||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||
uses: softprops/action-gh-release@v2
|
||
with:
|
||
tag_name: ${{ github.ref_name }}
|
||
append_body: true
|
||
body_path: ${{ steps.checksums.outputs.checksums_file }}
|
||
files: ${{ steps.checksums.outputs.checksums_file }}
|
||
fail_on_unmatched_files: true
|
||
|
||
# ── 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
|
||
steps:
|
||
- name: Generate + apply GitHub release notes to the preview release
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
REPO: ${{ github.repository }}
|
||
run: |
|
||
set -euo pipefail
|
||
NOTES=$(gh api --method POST "repos/$REPO/releases/generate-notes" -f tag_name=preview --jq .body)
|
||
# Build a Contributors avatar strip from the PR authors GitHub listed
|
||
# in the notes ("by @handle in …"). Inline <a>/<img> render on the
|
||
# release page (GitHub strips inline styles, so avatars are square).
|
||
CONTRIB=""
|
||
HANDLES=$(printf '%s\n' "$NOTES" | grep -oE 'by @[A-Za-z0-9-]+' | sed 's/^by @//' | sort -u || true)
|
||
if [ -n "$HANDLES" ]; then
|
||
CONTRIB=$'## Contributors\n\n'
|
||
while IFS= read -r h; do
|
||
[ -z "$h" ] && continue
|
||
CONTRIB="$CONTRIB<a href=\"https://github.com/$h\" title=\"@$h\"><img src=\"https://github.com/$h.png?size=64\" width=\"48\" alt=\"@$h\"/></a> "
|
||
done <<< "$HANDLES"
|
||
fi
|
||
{
|
||
echo "> 🧪 **Rolling preview build from \`main\`** — newest features, less tested. Opt in via **Settings → About → Update channel → Preview**; switch back to Stable any time."
|
||
echo ""
|
||
echo "$NOTES"
|
||
echo ""
|
||
echo "$CONTRIB"
|
||
} > /tmp/preview-notes.md
|
||
# --prerelease re-asserts the flag every run: a non-prerelease
|
||
# `preview` release is eligible to become GitHub's "Latest", which is
|
||
# the exact URL the Stable updater channel reads — so it must never
|
||
# flip off.
|
||
gh release edit preview --repo "$REPO" --prerelease --notes-file /tmp/preview-notes.md
|
||
echo "Applied auto-generated release notes + contributors to the preview release."
|
||
|
||
- name: Verify preview updater manifest (prerelease + platform parity)
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
REPO: ${{ github.repository }}
|
||
run: |
|
||
set -euo pipefail
|
||
# The preview release must stay a prerelease (or it can hijack the
|
||
# Stable channel's releases/latest endpoint), and its updater manifest
|
||
# must cover every platform stable does (else those users — e.g. Intel
|
||
# Mac — silently get no preview updates).
|
||
is_pre=$(gh release view preview --repo "$REPO" --json isPrerelease -q .isPrerelease)
|
||
test "$is_pre" = "true" || { echo "::error::preview release is not a prerelease"; exit 1; }
|
||
curl -fsSL "https://github.com/$REPO/releases/download/preview/latest.json" -o /tmp/preview-latest.json
|
||
curl -fsSL "https://github.com/$REPO/releases/latest/download/latest.json" -o /tmp/stable-latest.json
|
||
python3 - <<'PY'
|
||
import json, re
|
||
prev = json.load(open("/tmp/preview-latest.json"))
|
||
stab = json.load(open("/tmp/stable-latest.json"))
|
||
v = prev.get("version", "")
|
||
assert re.fullmatch(r"\d+\.\d+\.\d+-\d+", v), f"preview version not X.Y.Z-N: {v!r}"
|
||
pk, sk = set(prev.get("platforms", {})), set(stab.get("platforms", {}))
|
||
missing = sk - pk
|
||
assert not missing, f"preview manifest missing platforms vs stable: {sorted(missing)}"
|
||
print(f"preview manifest OK: {v} platforms={sorted(pk)}")
|
||
PY
|
||
|
||
# ── Post-release version bump (OWNER-GATED as of 2026-07-01) ──────────────
|
||
# Previously auto-ran after every stable v* tag to keep main = release + 1.
|
||
# The owner now controls bumps manually ("keep 0.3.8; I say when to bump"), so
|
||
# this job is OPT-IN: it runs ONLY when the repo variable AUTO_VERSION_BUMP is
|
||
# set to 'true' (Settings → Secrets and variables → Actions → Variables).
|
||
# Unset/anything-else → main stays at whatever it is after release. Re-enable
|
||
# by setting the variable; disable again by unsetting it.
|
||
version-bump:
|
||
if: >-
|
||
github.event_name == 'push' && github.ref_type == 'tag'
|
||
&& !contains(github.ref, '-')
|
||
&& vars.AUTO_VERSION_BUMP == 'true'
|
||
runs-on: ubuntu-22.04
|
||
permissions:
|
||
contents: write
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
with:
|
||
ref: main
|
||
fetch-depth: 0
|
||
- name: Bump main to released version + 1 patch
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
RELEASED="${GITHUB_REF_NAME#v}"
|
||
IFS=. read -r MAJ MIN PAT <<< "$RELEASED"
|
||
NEXT="$MAJ.$MIN.$((PAT + 1))"
|
||
# frontend/package.json is the SINGLE SOURCE OF TRUTH: vite injects
|
||
# __APP_VERSION__ from it, and tauri.conf.json reads its bundle version
|
||
# from it ("version": "../package.json"). Read CURRENT from it.
|
||
CURRENT=$(jq -r .version frontend/package.json)
|
||
if [ "$(printf '%s\n' "$NEXT" "$CURRENT" | sort -V | tail -1)" = "$CURRENT" ] && [ "$NEXT" != "$CURRENT" ]; then
|
||
echo "main is already at $CURRENT (>= $NEXT) — nothing to bump"; exit 0
|
||
fi
|
||
# Bump the canonical (package.json), set absolutely so any prior drift
|
||
# self-heals. tauri.conf.json needs no edit — it derives from this.
|
||
tmp=$(mktemp)
|
||
jq --arg v "$NEXT" '.version = $v' frontend/package.json > "$tmp"
|
||
mv "$tmp" frontend/package.json
|
||
# The remaining files are CI-guarded mirrors (cargo/uv require a
|
||
# literal; the version.py literal is the frozen-backend last resort) —
|
||
# bump them in lockstep with the canonical.
|
||
sed -i "0,/^version = \"$CURRENT\"/s//version = \"$NEXT\"/" frontend/src-tauri/Cargo.toml
|
||
sed -i "0,/^version = \"$CURRENT\"/s//version = \"$NEXT\"/" pyproject.toml
|
||
sed -i "0,/_FALLBACK_VERSION = \"$CURRENT\"/s//_FALLBACK_VERSION = \"$NEXT\"/" backend/core/version.py
|
||
git config user.name "github-actions[bot]"
|
||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||
git add frontend/package.json frontend/src-tauri/Cargo.toml pyproject.toml backend/core/version.py
|
||
git commit -m "chore(version): main -> $NEXT after $GITHUB_REF_NAME release"
|
||
git push origin main
|