# 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) from the selected branch" required: false type: boolean default: false permissions: contents: write # needed to attach artifacts + updater manifest to GH Release env: # Run all JavaScript actions on Node 24 (GH deprecates Node 20 in Sep 2026). FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: # Fast gating job — runs backend pytest + frontend node:test + tsc on a # single Linux runner. The matrix build below waits on this via `needs:` # so we don't burn 4× platform-matrix minutes on a broken commit. test: name: Tests (backend + frontend) runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - name: Setup Python 3.11 uses: actions/setup-python@v5 with: python-version: "3.11" # enable-cache persists ~/.cache/uv keyed on uv.lock. - name: Install uv uses: astral-sh/setup-uv@v3 with: enable-cache: true cache-dependency-glob: "uv.lock" # Node 22 is needed for --experimental-strip-types so node:test can # import .ts files directly from frontend/src/api/*. - name: Setup Node 22 uses: actions/setup-node@v4 with: node-version: '22' - name: Setup Bun uses: oven-sh/setup-bun@v1 # Backend tests need ffmpeg (subprocess calls in fixtures). Cache the # resolved .debs so warm runs skip the apt-get update + install. - name: System deps (ffmpeg) uses: awalsh128/cache-apt-pkgs-action@latest with: packages: ffmpeg version: 1.0 - name: Install Python deps run: uv sync - name: Run pytest run: uv run pytest tests/ -q --tb=short - name: Cache bun deps uses: actions/cache@v4 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }} restore-keys: | ${{ runner.os }}-bun- - name: Install frontend deps working-directory: frontend run: bun install # Single-sourced typecheck command (mirrors ci.yml). The `typecheck:ci` # script in frontend/package.json sets `--checkJs false` so pre-existing # JS-side errors don't block the release; only .ts/.tsx files gate. # Drift between CI and release-time typecheck flags broke v0.3.x release # runs — keep this command identical to ci.yml's step. - name: Frontend typecheck working-directory: frontend run: bun run typecheck:ci # Invoke node directly (not `bun run test`) because `bun run` auto-aliases # `node` to `bun` in script bodies, and bun doesn't support # --experimental-strip-types. - name: Run frontend node:test working-directory: frontend run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs # 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 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.31+ host, and is the # Linux auto-update target. The .deb target was dropped: tauri-bundler # fails it with "Failed to create control scripts: No such file or # directory" (no custom deb config of ours is at fault) — revisit on a # tauri-cli bump. FUSE unavailability on GH runners is handled via # APPIMAGE_EXTRACT_AND_RUN=1. - os: ubuntu-22.04 arch: x86_64-unknown-linux-gnu label: "Linux x64" rust_target: x86_64-unknown-linux-gnu bundles: "appimage,updater" runs-on: ${{ matrix.os }} name: ${{ matrix.label }} steps: - uses: actions/checkout@v4 # ── Language runtimes ────────────────────────────────────────────── - name: Setup Rust (stable) uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.rust_target }} # Cache ~/.cargo/registry + {target}/ per rust_target. Cargo dep # compile is the long pole of the build — cold is ~5-7 min, warm # drops to ~1-2 min. - name: Rust cache uses: Swatinem/rust-cache@v2 with: workspaces: frontend/src-tauri -> target key: ${{ matrix.rust_target }} - name: Setup Bun uses: oven-sh/setup-bun@v1 # ── Platform deps (Tauri host requirements only — no Python here) ─ # The runtime Python/uv bootstrap happens on the user's machine at # first launch, not in CI. CI only packages the source (pyproject.toml, # uv.lock, backend/*.py) into the Tauri installer as resources. - name: macOS system deps if: runner.os == 'macOS' run: | brew install ffmpeg || true - name: Linux system deps if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y \ libwebkit2gtk-4.1-dev \ build-essential curl wget file libxdo-dev libssl-dev \ libayatana-appindicator3-dev librsvg2-dev \ libasound2-dev ffmpeg # ── Frontend build ───────────────────────────────────────────────── - name: Cache bun deps uses: actions/cache@v4 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }} restore-keys: | ${{ runner.os }}-bun- - name: Install frontend deps working-directory: frontend run: bun install # ── Tauri build + sign + publish ─────────────────────────────────── # tauri-action handles: bundle, sign updater payload with the # TAURI_SIGNING_PRIVATE_KEY secret, attach to release, update # latest.json with per-platform download URLs & signatures. The # installer ships the repo's pyproject.toml + uv.lock + backend/ # tree as Tauri resources; lib.rs::ensure_venv_ready recreates the # venv on first launch via `uv sync --frozen --no-dev`. # Fetch the standalone `uv` binary for the current matrix target and # drop it at `binaries/uv-{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 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" - name: Build + release (Tauri) uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} # macOS Apple signing (#134 / #72) is configured by the preceding # "Configure Apple signing" step — it exports APPLE_* to $GITHUB_ENV # only on the opt-in stable path, leaving them ABSENT (not "") on # preview/unsigned paths so Tauri's bundler skips cert import. A static # env: here would always set them to "" and break the mac build. # 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-