# Desktop release pipeline — self-updating binaries for mac/linux/windows. # # Triggers: # - push of a tag matching `v*` (e.g. `v0.2.0`) → full STABLE release, # publishes artifacts + signed updater manifest (`latest.json`) to the # tag's GH Release. This is the default Stable updater channel. # - schedule (nightly, 07:00 UTC) → rolling `preview` PRERELEASE built from # `main` with its own signed `latest.json` at releases/download/preview/. # Feeds the opt-in Preview updater channel (Settings → About → Update # channel). The `preview-gate` job skips the matrix on nights when `main` # didn't move, so an idle day costs only a ~30s gate job — keeping Preview # ≤24h behind `main` at a predictable ~1-matrix/day cost. The stable # `latest` release is untouched. # - workflow_dispatch (publish_preview=true) → the same preview build on # demand from the selected branch (e.g. to preview a feature branch, or to # refresh immediately without waiting for the nightly). # - workflow_dispatch (publish_preview=false) → on-demand build (prior # behavior; draft release named after the branch). # # Strategy: matrix builds per target. Each runner produces a PyInstaller # frozen backend + Tauri bundle. `tauri-apps/tauri-action` signs the updater # payloads with TAURI_SIGNING_PRIVATE_KEY and uploads to the GH Release for # the tag. The built-in updater plugin polls the release's `latest.json` on # client boot. # # Windows/Linux support: first-pass enabled. Expect the first few runs on # each to surface PyInstaller/Tauri issues that never showed up locally on # macOS — iterate on CI. name: Desktop Release on: push: tags: ['v*'] schedule: # 07:00 UTC daily — rolling `preview` prerelease from `main`. The # preview-gate job no-ops the matrix when main hasn't moved in a day. - cron: '0 7 * * *' workflow_dispatch: inputs: draft: description: "Create as draft release (tag push only)" required: false default: "true" publish_preview: description: "Publish a rolling 'preview' prerelease (updater Preview channel). Previews ALWAYS build from main — dispatching from any other branch fails the preview-gate." required: false type: boolean default: false permissions: contents: write # needed to attach artifacts + updater manifest to GH Release # Every preview build publishes to the SAME rolling `preview` release, and the # updater manifest is rebuilt from whatever assets are on it. Two overlapping # preview runs (the nightly schedule and a manual dispatch, say) would upload # into each other's asset set, and the version-less macOS tarballs carry # nothing saying which run produced them — so one run could publish a manifest # advertising its own version while serving the other run's macOS binaries # (greptile). Serialize instead. Keyed on the ref, so a `v*` tag push (which # builds its own release and never touches `preview`) is never queued behind a # nightly. concurrency: group: desktop-release-${{ github.ref }} cancel-in-progress: false env: # Run all JavaScript actions on Node 24 (GH deprecates Node 20 in Sep 2026). FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: # Fast gating job — runs backend pytest + frontend node:test + tsc on a # single Linux runner. The matrix build below waits on this via `needs:` # so we don't burn 4× platform-matrix minutes on a broken commit. test: name: Tests (backend + frontend) runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - name: Setup Python 3.11 uses: actions/setup-python@v5 with: python-version: "3.11" # enable-cache persists ~/.cache/uv keyed on uv.lock. - name: Install uv uses: astral-sh/setup-uv@v3 with: enable-cache: true cache-dependency-glob: "uv.lock" # Node 22 is needed for --experimental-strip-types so node:test can # import .ts files directly from frontend/src/api/*. - name: Setup Node 22 uses: actions/setup-node@v4 with: node-version: '22' - name: Setup Bun uses: oven-sh/setup-bun@v1 # Backend tests need ffmpeg (subprocess calls in fixtures). Cache the # resolved .debs so warm runs skip the apt-get update + install. - name: System deps (ffmpeg) uses: awalsh128/cache-apt-pkgs-action@v1.6.3 with: packages: ffmpeg version: 1.0 - name: Install Python deps run: bash scripts/uv-sync-retry.sh - name: Run pytest run: uv run --no-sync 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 permissions: contents: read outputs: is_preview: ${{ steps.decide.outputs.is_preview }} proceed: ${{ steps.decide.outputs.proceed }} stable_tag: ${{ steps.decide.outputs.stable_tag }} steps: - uses: actions/checkout@v4 with: fetch-depth: 50 - id: decide shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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" # Resolve once before the matrix starts so every platform stamps # against the same immutable Stable-channel snapshot. STABLE_TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName) [[ "$STABLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::latest stable release has an invalid tag"; exit 1; } echo "stable_tag=$STABLE_TAG" >> "$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-{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<> "$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 with a numeric prerelease that is strictly above the # latest stable release. Main may intentionally retain the released # version while AUTO_VERSION_BUMP is disabled; in that case the helper # advances the preview base by one patch so stable users can still opt in # and receive it. The edit is ephemeral and never committed. - name: Stamp preview version if: needs.preview-gate.outputs.is_preview == 'true' shell: bash env: STABLE_TAG: ${{ needs.preview-gate.outputs.stable_tag }} run: | set -euo pipefail PREVIEW_VERSION=$(python scripts/stamp-preview-version.py \ --package-json frontend/package.json \ --stable-tag "$STABLE_TAG" \ --run-number "${{ github.run_number }}") echo "Stamped preview version: $PREVIEW_VERSION" # The rolling `preview` release is REUSED every night, and macOS updater # artifacts are the only ones Tauri names WITHOUT the version: # # VoiceStudio_0.4.1-103_x64.dmg <- unique per run, uploads fine # VoiceStudio_x64.app.tar.gz <- constant, collides # # So every preview build after the first failed the macOS legs with # `Validation Failed: {"resource":"ReleaseAsset","code":"already_exists"}` # — and it failed AFTER the dmg upload, so the run went red while looking # partially successful. The macOS updater bundles on `preview` went stale # on 2026-07-04/05 and stayed that way for three weeks: Preview-channel # macOS users had no working update path, and the nightly run was red # every night. # # Delete this arch's updater bundle before uploading the new one. Scoped # to the preview path (a `v*` tag makes a fresh release, nothing to # collide with) and to this job's own arch, so the parallel aarch64/x64 # legs never touch each other's assets. # # ONLY an absent release/asset is benign. Auth, permission, rate-limit and # network failures must not be swallowed: the step would report success # while the stale asset survived, the upload would then die with # `already_exists`, and we would be back to the exact outage this step # exists to prevent — minus the red step that explains why. Since GH_TOKEN # is scoped to this same repo, a 404 really does mean "not there". - name: Clear this arch's stale preview updater bundle (macOS) if: needs.preview-gate.outputs.is_preview == 'true' && runner.os == 'macOS' shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -uo pipefail # aarch64-apple-darwin -> aarch64 ; x86_64-apple-darwin -> x64 case "${{ matrix.arch }}" in aarch64-*) SUFFIX=aarch64 ;; x86_64-*) SUFFIX=x64 ;; *) echo "::error::unexpected arch ${{ matrix.arch }}"; exit 1 ;; esac # Match the STORED name, not the uploaded one. GitHub rewrites # spaces to dots, which is why the pre-rename product ("OmniVoice # Studio") was stored as "OmniVoice.Studio_x64.app.tar.gz". # "VoiceStudio" has no space and so needs no translation — the # pattern below matches both, so a preview release still holding # pre-rename assets is still cleaned up. if ! gh release view preview --json assets -q '.assets[].name' \ > /tmp/preview-assets.txt 2> /tmp/gh-view-err.txt; then if grep -qiE 'not found|HTTP 404' /tmp/gh-view-err.txt; then echo "No preview release yet — nothing to clear." exit 0 fi echo "::error::Could not read the preview release, so a stale ${SUFFIX} bundle may still be there." echo "Refusing to continue blind — the Tauri upload would fail with already_exists." cat /tmp/gh-view-err.txt exit 1 fi grep -E "(^VoiceStudio|[ .]Studio)_${SUFFIX}\.app\.tar\.gz(\.sig)?$" /tmp/preview-assets.txt \ > /tmp/stale.txt || true if [ ! -s /tmp/stale.txt ]; then echo "No stale ${SUFFIX} updater bundle on preview — nothing to clear." exit 0 fi while IFS= read -r name; do echo "Removing stale preview asset: $name" if ! gh release delete-asset preview "$name" --yes \ 2> /tmp/gh-del-err.txt; then # Already gone is fine — a re-run or the sibling leg beat us to # it, and the goal (no asset under this name) is met either way. if grep -qiE 'not found|HTTP 404' /tmp/gh-del-err.txt; then echo " (already gone — nothing to collide with)" continue fi echo "::error::Failed to delete stale preview asset $name." cat /tmp/gh-del-err.txt exit 1 fi done < /tmp/stale.txt - name: Build + release (Tauri) uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Analytics destination, injected at BUILD time (never committed — a # token-shaped literal in the repo trips the secret scanner, and the # frontend bundle is where a publishable client key belongs). Absent => # the build has no destination, the Privacy toggle isn't offered, and # nothing can be sent. Analytics still requires the user to opt in. VITE_POSTHOG_KEY: ${{ secrets.POSTHOG_PROJECT_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} # macOS Apple signing (#134 / #72) is configured by the preceding # "Configure Apple signing" step — it exports APPLE_* to $GITHUB_ENV # only on the opt-in stable path, leaving them ABSENT (not "") on # preview/unsigned paths so Tauri's bundler skips cert import. A static # env: here would always set them to "" and break the mac build. # Unsigned paths still get a VALID ad-hoc seal from tauri.conf.json # (bundle.macOS.signingIdentity = "-"), so a downloaded build shows the # GUI-bypassable "unidentified developer" prompt (right-click → Open / # Settings → "Open Anyway") instead of the un-bypassable "damaged" # error. On the signed path APPLE_SIGNING_IDENTITY (env) overrides the # "-" default; once notarized, Gatekeeper accepts it with no prompt. # GH runners disable FUSE, so linuxdeploy's AppImage can't mount # itself at bundle time. This env tells linuxdeploy to extract-and-run # instead, which works without FUSE. APPIMAGE_EXTRACT_AND_RUN: 1 with: projectPath: frontend args: --target ${{ matrix.rust_target }} --bundles ${{ matrix.bundles }} # Preview path (workflow_dispatch + publish_preview=true) targets a # rolling `preview` prerelease for the updater's Preview channel. # Every other invocation — crucially the `v*` tag-push stable release # — evaluates these expressions to exactly their prior values. tagName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }} # Version-first so the tag is readable in GitHub's truncated # release-list sidebar (which clips the title mid-string). releaseName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'Preview — VoiceStudio' || format('{0} — VoiceStudio', github.ref_name) }} releaseBody: ${{ steps.changelog.outputs.body }} releaseDraft: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'false' || (inputs.draft || 'true') }} prerelease: ${{ needs.preview-gate.outputs.is_preview == 'true' }} updaterJsonPreferNsis: false includeUpdaterJson: true - name: Build per-user Windows MSI if: runner.os == 'Windows' shell: bash working-directory: frontend env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail python ../scripts/render-per-user-wix.py \ --source src-tauri/wix/main.wxs \ --output src-tauri/target/wix-per-user/main.wxs bunx tauri build --target ${{ matrix.rust_target }} --bundles msi \ --config src-tauri/tauri.per-user.conf.json DIR="src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi" while IFS= read -r artifact; do safe=${artifact// (Current User)/_Current_User} [ "$safe" = "$artifact" ] || mv "$artifact" "$safe" done < <(find "$DIR" -maxdepth 1 -type f -name '*Current*User*.msi*') - name: Publish per-user Windows updater channel if: runner.os == 'Windows' shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }} run: | set -euo pipefail DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi" MSI=$(find "$DIR" -name '*Current*User*.msi' -type f | head -1) [ -n "$MSI" ] || { echo "per-user MSI missing"; find "$DIR" -type f; exit 1; } [ -f "$MSI.sig" ] || { echo "per-user MSI signature missing"; exit 1; } VERSION=$(jq -r .version frontend/package.json) python scripts/build_windows_user_manifest.py \ --repo "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --version "$VERSION" \ --asset "$(basename "$MSI")" --signature-file "$MSI.sig" \ --output latest-user.json gh release upload "$RELEASE_TAG" "$MSI" "$MSI.sig" latest-user.json \ --clobber --repo "$GITHUB_REPOSITORY" # ── Installer smoke (Phase 0 GATE-03) ───────────────────────────── # Structural verification of the installed/extracted bundle. The thin # uv-venv installer ships NO frozen backend binary (the venv is built on # first launch via the bundled `uv`), so there is nothing to boot with # `--health-check` here. Instead assert the bundle carries the shell # binary, the bundled `uv` sidecar, and the backend source resources # (pyproject.toml + backend/main.py) — the real "is the bundle complete" # regression that ci.yml's in-process smoke can't catch. - name: Installer smoke (macOS) if: runner.os == 'macOS' timeout-minutes: 5 shell: bash run: | set -euo pipefail DMG=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg -name "*.dmg" | head -1) echo "Smoke-testing DMG: $DMG" # Grab the full mount path with a grep rather than `awk '{print $3}'`. # The volume name used to contain a space ("OmniVoice Studio"), which # awk truncated to /Volumes/OmniVoice; "VoiceStudio" has no space, so # this is now belt-and-braces rather than load-bearing. MOUNT=$(hdiutil attach -nobrowse -readonly "$DMG" | tail -1 | grep -oE '/Volumes/.*$') APP=$(find "$MOUNT" -maxdepth 2 -name "*.app" | head -1) fail() { echo "FAIL — $1"; find "$APP/Contents" -maxdepth 4 -type f 2>/dev/null | head -40; hdiutil detach "$MOUNT" || true; exit 1; } [ -n "$APP" ] || { echo "FAIL — no .app inside DMG"; hdiutil detach "$MOUNT" || true; exit 1; } # Thin uv-venv installer ships no frozen backend to boot — verify the # bundle is complete: shell binary + bundled uv sidecar + backend source. ls "$APP/Contents/MacOS"/* >/dev/null 2>&1 || fail "no shell binary in Contents/MacOS" find "$APP/Contents" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing" find "$APP/Contents" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing" find "$APP/Contents" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing" echo "OK — bundle has shell + uv + backend resources" hdiutil detach "$MOUNT" || true # ── Signing / Gatekeeper / notarization verification ────────────── # Runs codesign --verify, spctl (Gatekeeper), nested-binary, and # stapler checks against the built .app (see docs/macos-signing-verification.md). # STRICT (--require-signed) only on the opt-in signed stable path — same # condition as "Configure Apple signing" above — so a failed or missing # signature/notarization FAILS the job and STOPS the release instead of # publishing an unsigned artifact. On every other (unsigned dev/preview) # path it runs report-only and never breaks the build. - name: Verify macOS signing if: runner.os == 'macOS' shell: bash env: STRICT: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && vars.MACOS_SIGNING_ENABLED == 'true') && '1' || '0' }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -uo pipefail APP=$(find "frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos" -maxdepth 1 -name '*.app' | head -1) [ -n "$APP" ] || { echo "FAIL — no .app found to verify"; exit 1; } MODE="" if [ "$STRICT" = "1" ]; then MODE="--require-signed" echo "Signed stable release → STRICT verification (release stops on failure)." else echo "Unsigned dev/preview path → report-only verification." fi bash scripts/verify-macos-signing.sh "$APP" $MODE - name: Installer smoke (Windows) if: runner.os == 'Windows' timeout-minutes: 5 shell: bash run: | set -euo pipefail MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" ! -name '*Current*User*' | head -1) echo "Smoke-testing MSI: $MSI" powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/verify-windows-msi.ps1 -MsiPath "$(cygpath -w "$MSI")" # /quiet = no UI, /norestart = don't reboot the runner if a dep asks msiexec.exe //i "$(cygpath -w "$MSI")" //quiet //norestart INSTALL="/c/Program Files/VoiceStudio" fail() { echo "FAIL — $1. Contents:"; find "$INSTALL" -maxdepth 4 -type f 2>/dev/null | head -40; exit 1; } # Thin uv-venv installer ships no frozen backend .exe — verify the # install is complete: shell exe + bundled uv + backend source resources. test -f "$INSTALL/omnivoice-studio.exe" || fail "shell exe missing" test -f "$INSTALL/uv.exe" || fail "bundled uv missing" find "$INSTALL" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing" find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing" echo "OK — MSI installed shell + uv + backend resources" - name: Per-user installer smoke (Windows, non-admin account) if: runner.os == 'Windows' timeout-minutes: 8 shell: bash run: | set -euo pipefail MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name '*Current*User*.msi' | head -1) powershell.exe -NoProfile -ExecutionPolicy Bypass \ -File scripts/smoke-per-user-msi.ps1 -MsiPath "$(cygpath -w "$MSI")" # linuxdeploy re-links .DirIcon as an ABSOLUTE symlink into the build # machine AFTER tauri's files-map has placed the real icon bytes — the # exact bug #1518 guarded against, resurfacing on the first real tag # build (v0.5.0). The seam tauri-action leaves us is post-upload: repack # the AppImage with the icon as a REGULAR FILE, re-sign it (the updater # signature covered the old bytes), and clobber the draft release's # asset + the linux signature inside latest.json. The smoke below then # validates the repaired artifact, not the broken one. - name: Repair AppImage .DirIcon, re-sign, re-upload if: runner.os == 'Linux' timeout-minutes: 10 shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} # Data, not shell source (zizmor template-injection): a crafted ref # must never expand inside a script that holds the signing key. TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }} run: | set -euo pipefail APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1) APPIMAGE=$(realpath "$APPIMAGE") WORK="$(mktemp -d)"; cd "$WORK" "$APPIMAGE" --appimage-extract >/dev/null ROOT="$WORK/squashfs-root" ICON=$(readlink -f "$ROOT/.DirIcon" 2>/dev/null || true) if [ -n "$ICON" ] && [ -f "$ICON" ] && case "$ICON" in "$ROOT"/*) true;; *) false;; esac; then echo ".DirIcon already resolves inside the bundle — no repair needed" exit 0 fi # The real bytes are at the AppDir root (linuxdeploy put them there # before mislinking). Ship a regular file: nothing left to dangle. SRC=$(find "$ROOT" -maxdepth 1 -name "*.png" | head -1) [ -n "$SRC" ] || SRC=$(find "$ROOT/usr/share/icons" -name "*.png" | head -1) [ -n "$SRC" ] || { echo "no icon bytes found in bundle"; exit 1; } rm -f "$ROOT/.DirIcon" cp "$SRC" "$ROOT/.DirIcon" # Pinned immutable release + checksum: this binary runs with the # updater signing key and a release-write token in its environment, # so a mutable 'continuous' asset is not acceptable supply chain. AIT_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage" AIT_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0" curl -fsSL --retry 3 -o "$WORK/appimagetool" "$AIT_URL" echo "$AIT_SHA256 $WORK/appimagetool" | sha256sum -c - || { echo "appimagetool checksum mismatch"; exit 1; } chmod +x "$WORK/appimagetool" # Same FUSE-less trick the build itself uses. APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$WORK/appimagetool" --no-appstream "$ROOT" "$APPIMAGE" cd "$GITHUB_WORKSPACE/frontend" bunx tauri signer sign "$APPIMAGE" gh release upload "$TAG" "$APPIMAGE" "$APPIMAGE.sig" --clobber --repo "$GITHUB_REPOSITORY" # latest.json is NOT patched here: every tauri-action leg re-uploads # the shared manifest, so an in-leg patch races the other platforms — # the repair-updater-manifest job below is the single final writer. echo "repacked, re-signed, re-uploaded" - 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; } # linuxdeploy's GTK/GStreamer hooks wrap the seeded launcher as # AppRun.wrapped. Verify the complete launcher chain, not only the # small hook runner installed at the AppImage root. bash "$GITHUB_WORKSPACE/scripts/verify-apprun-bundle.sh" \ "$ROOT" \ "$GITHUB_WORKSPACE/frontend/src-tauri/appimage/AppRun" \ "$GITHUB_WORKSPACE/frontend/src-tauri/target/.tauri/bundled-webkitgtk-version" # Thin uv-venv installer: verify the AppImage carries the shell binary, # the bundled uv sidecar, and the backend source resources. { [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "VoiceStudio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing" find "$ROOT" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing" find "$ROOT" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing" find "$ROOT" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing" echo "OK — AppImage has shell + uv + backend resources" # ── Compute SHA-256 checksums (Phase 0 GATE-05) ─────────────────── # Native OS tools: shasum -a 256 (POSIX) / Get-FileHash (Windows). # Writes SHA256SUMS-