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