Files
Handy/.github/workflows/build.yml
CJ Pais 5197faf0e6 actually remove onnx (#2279)
* actually remove onnx

* fix: link clang_rt.osx and drop stale ORT build cache

ort-sys linked libclang_rt.osx.a, which also supplied ggml-metal's
___isPlatformVersionAtLeast. Without ORT, macOS arm64 builds targeting
11.0 fail to link, so Handy now links it directly.

Bump the rust-cache key so restored target dirs no longer carry ORT
leftovers like DirectML.dll, which broke Windows ARM64 signing.

* change acceleration string
2026-10-15 07:54:22 +08:00

816 lines
41 KiB
YAML

name: "Build"
on:
workflow_call:
inputs:
platform:
required: true
type: string
target:
required: true
type: string
build-args:
required: false
type: string
default: ""
release-id:
required: false
type: string
asset-prefix:
required: false
type: string
default: "handy"
asset-name-pattern:
required: false
type: string
default: ""
upload-artifacts:
required: false
type: boolean
default: false
sign-binaries:
required: false
type: boolean
default: false
repository:
required: false
type: string
ref:
required: false
type: string
default: ${{ github.ref }}
is-debug-build:
required: false
type: boolean
default: false
no-cache:
required: false
type: boolean
default: false
env:
TSC_VERSION: "0.9.0"
jobs:
build:
permissions:
contents: write
runs-on: ${{ inputs.platform }}
steps:
# Kept for git checkout and general tooling headroom. The native
# transcribe-cpp-sys build no longer needs path help: since 0.1.3 its
# build.rs self-mitigates MAX_PATH by compiling through a short NTFS
# junction (and ggml's Vulkan shader-gen ExternalProject is flattened),
# so no CARGO_TARGET_DIR redirect is required — upstream CI proves the
# deep-path build with long paths forced OFF.
- name: Enable long paths (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" `
-Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force
git config --system core.longpaths true
- name: Checkout repository
uses: actions/checkout@v5
with:
repository: ${{ inputs.repository }}
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Get version from tauri.conf.json
id: get-version
shell: bash
run: |
VERSION=$(grep -o '"version": "[^"]*"' src-tauri/tauri.conf.json | cut -d'"' -f4)
echo "Application version from tauri.conf.json: $VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Determine build profile
id: build-profile
shell: bash
run: |
if [[ "${{ inputs.is-debug-build }}" == "true" ]]; then
echo "profile=debug" >> "$GITHUB_OUTPUT"
echo "Build profile: debug"
else
echo "profile=release" >> "$GITHUB_OUTPUT"
echo "Build profile: release"
fi
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
targets: ${{ contains(inputs.platform, 'macos') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Rust cache
# Release builds skip the cache (no-cache: true) so the artifact is
# always compiled clean — no risk of a stale cached native lib being
# linked. PR/test builds keep caching for speed.
if: ${{ !inputs.no-cache }}
uses: swatinem/rust-cache@v2
with:
workspaces: "./src-tauri -> target"
key: ${{ inputs.platform }}-${{ inputs.target }}-no-bin-v2
cache-bin: false
- name: install dependencies (ubuntu 24.04 x64)
if: contains(inputs.platform, 'ubuntu-24.04') && !contains(inputs.platform, 'arm')
run: |
sudo apt-get update
sudo apt-get install -y libappindicator3-dev librsvg2-dev patchelf libasound2-dev libopenblas-dev libx11-dev libxtst-dev libxrandr-dev libgtk-layer-shell0 libgtk-layer-shell-dev \
libwebkit2gtk-4.1-0=2.44.0-2 \
libwebkit2gtk-4.1-dev=2.44.0-2 \
libjavascriptcoregtk-4.1-0=2.44.0-2 \
libjavascriptcoregtk-4.1-dev=2.44.0-2 \
gir1.2-javascriptcoregtk-4.1=2.44.0-2 \
gir1.2-webkit2-4.1=2.44.0-2
- name: install dependencies (ubuntu 24.04 arm64)
if: contains(inputs.platform, 'ubuntu-24.04-arm')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev libopenblas-dev libx11-dev libxtst-dev libxrandr-dev libgtk-layer-shell0 libgtk-layer-shell-dev xdg-utils
- name: install dependencies (ubuntu 22.04)
if: contains(inputs.platform, 'ubuntu-22.04') && !contains(inputs.platform, 'arm')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev libopenblas-dev libx11-dev libxtst-dev libxrandr-dev libgtk-layer-shell0 libgtk-layer-shell-dev
- name: Verify gtk-layer-shell runtime dependency (Ubuntu)
if: contains(inputs.platform, 'ubuntu')
run: |
dpkg-query -W -f='${Status}\n' libgtk-layer-shell0 | grep -q "install ok installed"
ldconfig -p | grep -q "libgtk-layer-shell.so.0"
- name: Verify OpenBLAS runtime dependency (Ubuntu)
if: contains(inputs.platform, 'ubuntu')
run: |
ldconfig -p | grep -q "libopenblas.so.0"
# x86_64 only — Windows-on-ARM links transcribe-cpp statically CPU-only
# (no `vulkan` feature; see src-tauri/Cargo.toml), so ggml-vulkan is never
# built there and no Vulkan SDK, headers, or glslc are needed.
- name: Install Vulkan SDK (Windows x64)
if: contains(inputs.platform, 'windows') && !contains(inputs.target, 'aarch64')
uses: humbletim/install-vulkan-sdk@v1.2
with:
version: 1.4.309.0
cache: true
- name: Cache trusted-signing-cli
if: contains(inputs.platform, 'windows') && inputs.sign-binaries
id: cache-tsc
uses: actions/cache@v5
with:
path: ~/.cargo/bin/trusted-signing-cli*
key: trusted-signing-cli-${{ env.TSC_VERSION }}-${{ runner.os }}-${{ runner.arch }}
- name: Install trusted-signing-cli
if: contains(inputs.platform, 'windows') && inputs.sign-binaries && steps.cache-tsc.outputs.cache-hit != 'true'
run: cargo install --locked --force trusted-signing-cli@${{ env.TSC_VERSION }}
- name: Prepare Vulkan SDK for Ubuntu 24.04
if: contains(inputs.platform, 'ubuntu-24.04') && !contains(inputs.platform, 'arm')
run: |
wget -qO- https://packages.lunarg.com/lunarg-signing-key-pub.asc | sudo tee /etc/apt/trusted.gpg.d/lunarg.asc
sudo wget -qO /etc/apt/sources.list.d/lunarg-vulkan-1.3.290-noble.list https://packages.lunarg.com/vulkan/1.3.290/lunarg-vulkan-1.3.290-noble.list
sudo apt update
sudo apt install vulkan-sdk -y
sudo apt-get install -y mesa-vulkan-drivers
- name: Prepare Vulkan SDK for Ubuntu ARM64
if: contains(inputs.platform, 'ubuntu') && contains(inputs.platform, 'arm')
uses: jakoch/install-vulkan-sdk-action@v1
with:
vulkan_version: 1.4.335.0
cache: true
- name: Install Vulkan runtime libraries (Ubuntu ARM64)
if: contains(inputs.platform, 'ubuntu') && contains(inputs.platform, 'arm')
run: |
sudo apt-get update
sudo apt-get install -y libvulkan-dev mesa-vulkan-drivers
- name: Prepare Vulkan SDK for Ubuntu 22.04
if: contains(inputs.platform, 'ubuntu-22.04') && !contains(inputs.platform, 'arm')
run: |
wget -qO- https://packages.lunarg.com/lunarg-signing-key-pub.asc | sudo tee /etc/apt/trusted.gpg.d/lunarg.asc
sudo wget -qO /etc/apt/sources.list.d/lunarg-vulkan-1.3.290-jammy.list https://packages.lunarg.com/vulkan/1.3.290/lunarg-vulkan-1.3.290-jammy.list
sudo apt update
sudo apt install vulkan-sdk -y
sudo apt-get install -y mesa-vulkan-drivers
- name: install frontend dependencies
if: ${{ !(contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64')) }}
run: bun install
- name: install frontend dependencies (Windows ARM64)
if: ${{ contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64') }}
run: bun install --cpu=arm64
- name: rustup install target
if: ${{ inputs.target != '' && !contains(inputs.target, 'x86_64-unknown-linux-gnu') && !contains(inputs.target, 'aarch64-unknown-linux-gnu') && !contains(inputs.target, 'x86_64-pc-windows-msvc') }}
run: rustup target add ${{ inputs.target }}
- name: import Apple Developer Certificate
if: contains(inputs.platform, 'macos') && inputs.sign-binaries
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
security find-identity -v -p codesigning build.keychain
- name: verify certificate
if: contains(inputs.platform, 'macos') && inputs.sign-binaries
run: |
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported."
- name: Patch asset name pattern
id: patch-release-name
shell: bash
if: ${{ inputs.release-id != '' && inputs.asset-name-pattern != '' }}
run: |
platform="${{ inputs.platform }}"
replacement="$(echo ${platform} | sed -E 's/-latest//')"
patched_platform=$(echo '${{ inputs.asset-name-pattern }}' | sed -E "s/\[platform\]/${replacement}/")
if [[ -n "${{ inputs.asset-prefix }}" ]]; then
patched_platform="${{ inputs.asset-prefix }}_${patched_platform}"
fi
echo "platform=${patched_platform}" >> $GITHUB_OUTPUT
# ggml refuses to build its ARM CPU backend under MSVC (upstream check in
# ggml/src/ggml-cpu/CMakeLists.txt: "MSVC is not supported for ARM, use
# clang"), so the Windows-ARM build must use clang-cl. transcribe-cpp-sys
# drives CMake via the cmake crate, which resolves the compiler through the
# cc crate — i.e. the CC/CXX env vars, NOT CMAKE_C_COMPILER (which is why an
# earlier CMAKE_C_COMPILER-only attempt still configured with MSVC cl.exe).
# Ninja (honored by the cmake crate) sidesteps the VS generator's toolset.
- name: Configure cmake for ARM64 (Windows)
if: contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64')
shell: pwsh
run: |
echo "CMAKE_GENERATOR=Ninja" >> $env:GITHUB_ENV
echo "CC=clang-cl" >> $env:GITHUB_ENV
echo "CXX=clang-cl" >> $env:GITHUB_ENV
echo "CMAKE_C_COMPILER=clang-cl" >> $env:GITHUB_ENV
echo "CMAKE_CXX_COMPILER=clang-cl" >> $env:GITHUB_ENV
echo "CMAKE_C_COMPILER_TARGET=aarch64-pc-windows-msvc" >> $env:GITHUB_ENV
echo "CMAKE_CXX_COMPILER_TARGET=aarch64-pc-windows-msvc" >> $env:GITHUB_ENV
echo "CL=/EHsc" >> $env:GITHUB_ENV
# Link uses lld-link + llvm-mt; use llvm-rc for resources too. Microsoft's
# rc.exe fails the manifest with "RC2136" under this clang-cl/lld toolchain
# (seen in ggml-vulkan's vulkan-shaders-gen host sub-build). CMake honors
# the RC env var, and the ExternalProject sub-build inherits it.
echo "RC=llvm-rc" >> $env:GITHUB_ENV
# Two ARM-static CMake overrides, forwarded by src-tauri/build.rs to the
# transcribe-cpp-sys configure via TRANSCRIBE_CMAKE_ARGS (applied AFTER
# the crate's own -D's, so ours win):
#
# GGML_NATIVE=OFF — only the dynamic-backends path force-sets this; a
# plain static build inherits ggml's default GGML_NATIVE=ON
# (= -mcpu=native), tuning the binary to THIS runner's CPU — a
# distribution hazard. Pin OFF for a portable ARM baseline.
#
# GGML_OPENMP=OFF — transcribe-cpp-sys force-sets GGML_OPENMP=ON on
# Windows, assuming MSVC auto-links vcomp via the /openmp pragma. But
# ARM builds with clang-cl, whose /openmp emits LLVM-OMP refs
# (__kmpc_*/omp_*) with no auto-linked runtime, so the static final
# link fails with LNK2019 unresolved externals. The crate's CENTRAL
# POLICY notes ggml's non-OpenMP barrier only deadlocks under MSVC
# *codegen* ("works on every other compiler"), and clang-cl is LLVM
# codegen — so disabling OpenMP is safe here and avoids shipping or
# statically linking libomp.
echo "TRANSCRIBE_CMAKE_ARGS=-DGGML_NATIVE=OFF -DGGML_OPENMP=OFF" >> $env:GITHUB_ENV
# Bundle the VC++ runtime app-local so Handy starts on machines without a
# current Microsoft redistributable. build.rs stages DLLs from these dirs
# into transcribe-libs/, using the same VS install that compiled the code.
# x64 also needs vcomp140.dll for ggml OpenMP; ARM64 disables OpenMP.
- name: Locate VC++ runtime redist for app-local bundling (Windows)
if: contains(inputs.platform, 'windows')
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$vsRoot = & $vswhere -latest -products * -property installationPath
if (-not $vsRoot) { throw "vswhere found no Visual Studio installation" }
$versionFile = Join-Path $vsRoot "VC\Auxiliary\Build\Microsoft.VCRedistVersion.default.txt"
$redistVersion = (Get-Content $versionFile).Trim()
$arch = if ("${{ inputs.target }}" -like "aarch64*") { "arm64" } else { "x64" }
$archDir = Join-Path $vsRoot "VC\Redist\MSVC\$redistVersion\$arch"
if (-not (Test-Path $archDir)) { throw "VC++ redist arch dir not found: $archDir" }
# Glob the VC generation folder; the runtime DLL names stay ABI-stable.
$crt = Get-ChildItem $archDir -Directory -Filter "Microsoft.VC*.CRT" |
Sort-Object Name | Select-Object -Last 1
if (-not $crt -or -not (Test-Path (Join-Path $crt.FullName "msvcp140.dll"))) {
throw "VC++ CRT redist dir not found or incomplete under: $archDir"
}
$dirs = $crt.FullName
if ($arch -eq "x64") {
$omp = Get-ChildItem $archDir -Directory -Filter "Microsoft.VC*.OpenMP" |
Sort-Object Name | Select-Object -Last 1
if (-not $omp -or -not (Test-Path (Join-Path $omp.FullName "vcomp140.dll"))) {
throw "VC++ OpenMP redist dir not found or incomplete under: $archDir"
}
$dirs = "$($crt.FullName);$($omp.FullName)"
}
Write-Host "HANDY_VC_REDIST_DIRS=$dirs"
Add-Content -Path $env:GITHUB_ENV -Value "HANDY_VC_REDIST_DIRS=$dirs"
# MSI packaging verifies bundled .dll/.exe resources with signtool before
# custom signing. Tauri's SDK lookup has no ARM64 host mapping, so pass
# the tool explicitly once ARM64 starts bundling runtime DLLs.
- name: Point tauri-bundler at signtool.exe (Windows ARM64)
if: contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64')
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$bin = "${env:ProgramFiles(x86)}\Windows Kits\10\bin"
$candidates = @(
Get-ChildItem "$bin\10.0.*\arm64\signtool.exe" -ErrorAction SilentlyContinue
Get-ChildItem "$bin\10.0.*\x64\signtool.exe" -ErrorAction SilentlyContinue
) | Where-Object { $_ }
if (-not $candidates) {
throw "signtool.exe not found under $bin (is a Windows SDK installed on this runner?)"
}
# Prefer native arm64 (x64 works too, under emulation); within an
# arch, the newest SDK version wins.
$signtool = $candidates |
Sort-Object { $_.FullName -match '\\arm64\\' }, FullName |
Select-Object -Last 1
Write-Host "TAURI_WINDOWS_SIGNTOOL_PATH=$($signtool.FullName)"
Add-Content -Path $env:GITHUB_ENV -Value "TAURI_WINDOWS_SIGNTOOL_PATH=$($signtool.FullName)"
# ggml-vulkan (built by transcribe-cpp-sys when the Vulkan backend is on)
# does `find_package(SPIRV-Headers CONFIG REQUIRED)` for <spirv-headers/
# spirv.hpp>. ggml's CMake appends $VULKAN_SDK to CMAKE_PREFIX_PATH first,
# but the CI Vulkan SDK install does not expose a findable
# SPIRV-HeadersConfig.cmake, so the configure fails. Provide the
# header-only package via vcpkg and put its prefix on CMAKE_PREFIX_PATH
# (the cmake crate forwards it as -DCMAKE_PREFIX_PATH). x86_64 only — the
# Windows ARM64 build is CPU-only, so ggml-vulkan is never built there.
# SPIRV-Headers is header-only, so the triplet is immaterial;
# SPIRV-HeadersConfig.cmake is a CONFIG package, so plain CMAKE_PREFIX_PATH
# resolves it with no vcpkg toolchain.
- name: Install SPIRV-Headers for transcribe-cpp Vulkan build (Windows x64)
if: contains(inputs.platform, 'windows') && !contains(inputs.target, 'aarch64')
shell: pwsh
run: |
vcpkg install spirv-headers:x64-windows
if ($LASTEXITCODE -ne 0) { throw "vcpkg install spirv-headers failed" }
$prefix = "$env:VCPKG_INSTALLATION_ROOT/installed/x64-windows" -replace '\\','/'
$combined = if ($env:CMAKE_PREFIX_PATH) { "$prefix;$env:CMAKE_PREFIX_PATH" } else { $prefix }
Add-Content $env:GITHUB_ENV "CMAKE_PREFIX_PATH=$combined"
Write-Host "CMAKE_PREFIX_PATH=$combined"
# transcribe-cpp ships as a shared libtranscribe + loadable ggml backend
# modules on Linux (the `dynamic-backends` posture). They are produced by
# transcribe-cpp-sys's CMake install during the cargo build, but nothing
# ships them. Pre-build the app so that install dir exists, then expose it
# via LD_LIBRARY_PATH so the AppImage's linuxdeploy can resolve
# libtranscribe.so (a NEEDED of `handy`) while bundling. tauri-action
# reuses this compile incrementally. build.rs stages the runtime and
# dlopen'd modules into transcribe-libs/, which tauri.conf.json includes.
- name: Pre-build and locate transcribe-cpp backend libs (Linux)
if: contains(inputs.platform, 'ubuntu')
shell: bash
run: |
set -euo pipefail
PROFILE="${{ steps.build-profile.outputs.profile }}"
# Frontend must exist for the app crate to compile (generate_context!).
bun run build
(
cd src-tauri
if [ "$PROFILE" = "release" ]; then cargo build --release; else cargo build; fi
)
# The install manifest sits next to libtranscribe in <crate-out>/lib.
# Pick the newest match in case a stale build dir lingers in the cache.
LIBDIR="$(find "src-tauri/target/$PROFILE/build" \
-type f -name transcribe-link.json -path '*transcribe-cpp-sys*/out/lib/*' \
-printf '%T@ %h\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)"
if [ -z "${LIBDIR:-}" ] || [ ! -d "$LIBDIR" ]; then
echo "ERROR: could not locate transcribe-cpp-sys install lib dir" >&2
find src-tauri/target \( -name 'libtranscribe*' -o -name 'libggml*' \) 2>/dev/null >&2 || true
exit 1
fi
LIBDIR="$(cd "$LIBDIR" && pwd)"
echo "Located transcribe-cpp libs in: $LIBDIR"
ls -la "$LIBDIR"
echo "LD_LIBRARY_PATH=$LIBDIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV"
# linuxdeploy resolves `handy`'s NEEDED libs via `ldd` and HARD-FAILS
# on an unresolved one (it does not warn-and-skip). libtranscribe.so
# lives only in the cargo build tree, so also publish it (and the ggml
# libs it pulls in) to the system linker cache — belt-and-suspenders
# with LD_LIBRARY_PATH so resolution never hinges on env propagation
# into the bundler subprocess. (Only affects the CI host; the AppImage
# gets its own co-located copies.)
sudo cp -vL "$LIBDIR"/libtranscribe.so* "$LIBDIR"/libggml*.so* /usr/local/lib/ 2>/dev/null || true
sudo ldconfig
# Tauri's mirrored 2024 linuxdeploy predates support for the
# LINUXDEPLOY_EXCLUDED_LIBRARIES environment variable. Put a current
# upstream build in Tauri's tool cache so the exclusions on the build step
# are applied while constructing the original, signed AppImage.
- name: Install linuxdeploy with library exclusion support
if: contains(inputs.platform, 'ubuntu') && contains(inputs.build-args, 'appimage')
shell: bash
run: |
set -euo pipefail
case "$(uname -m)" in
x86_64) arch="x86_64" ;;
aarch64|arm64) arch="aarch64" ;;
*) echo "Unsupported linuxdeploy architecture: $(uname -m)" >&2; exit 1 ;;
esac
tools_dir="${XDG_CACHE_HOME:-$HOME/.cache}/tauri"
mkdir -p "$tools_dir"
curl -L --fail --show-error --retry 3 \
-o "$tools_dir/linuxdeploy-${arch}.AppImage" \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${arch}.AppImage"
chmod +x "$tools_dir/linuxdeploy-${arch}.AppImage"
"$tools_dir/linuxdeploy-${arch}.AppImage" --appimage-extract-and-run --version
- name: Build with Tauri
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_ID: ${{ inputs.sign-binaries && secrets.APPLE_ID || '' }}
APPLE_ID_PASSWORD: ${{ inputs.sign-binaries && secrets.APPLE_ID_PASSWORD || '' }}
APPLE_PASSWORD: ${{ inputs.sign-binaries && secrets.APPLE_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ inputs.sign-binaries && secrets.APPLE_TEAM_ID || '' }}
APPLE_CERTIFICATE: ${{ inputs.sign-binaries && secrets.APPLE_CERTIFICATE || '' }}
APPLE_CERTIFICATE_PASSWORD: ${{ inputs.sign-binaries && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
APPLE_SIGNING_IDENTITY: ${{ inputs.sign-binaries && env.CERT_ID || '' }}
AZURE_CLIENT_ID: ${{ inputs.sign-binaries && secrets.AZURE_CLIENT_ID || '' }}
AZURE_CLIENT_SECRET: ${{ inputs.sign-binaries && secrets.AZURE_CLIENT_SECRET || '' }}
AZURE_TENANT_ID: ${{ inputs.sign-binaries && secrets.AZURE_TENANT_ID || '' }}
TAURI_SIGNING_PRIVATE_KEY: ${{ inputs.sign-binaries && secrets.TAURI_SIGNING_PRIVATE_KEY || '' }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ inputs.sign-binaries && secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || '' }}
# The Vulkan loader and Wayland client library must come from the host
# graphics stack. Bundling Ubuntu's copies ahead of the system libraries can
# prevent a newer distro's Vulkan ICD or Mesa stack from loading.
# linuxdeploy reads this semicolon-separated glob list before it creates
# (and Tauri signs and uploads) the AppImage, so the published artifact
# is already clean.
LINUXDEPLOY_EXCLUDED_LIBRARIES: ${{ contains(inputs.platform, 'ubuntu') && 'libvulkan.so*;libwayland-client.so*' || '' }}
# linuxdeploy passes this to appimagetool, which writes it as
# X-AppImage-Version for desktop integration tools such as Gear Lever.
LINUXDEPLOY_OUTPUT_VERSION: ${{ contains(inputs.platform, 'ubuntu') && steps.get-version.outputs.version || '' }}
with:
tagName: ${{ inputs.release-id && format('v{0}', steps.get-version.outputs.version) || '' }}
releaseName: ${{ inputs.release-id && format('v{0}', steps.get-version.outputs.version) || '' }}
releaseId: ${{ inputs.release-id }}
assetNamePattern: ${{ steps.patch-release-name.outputs.platform }}
args: ${{ inputs.build-args }}
# tauri-action uploads release assets before returning. All AppImage
# filtering therefore happens inside linuxdeploy above; this verifies the
# exact artifact Tauri signed and uploaded.
- name: Verify AppImage version metadata
if: contains(inputs.platform, 'ubuntu')
shell: bash
run: |
set -euo pipefail
PROFILE="${{ steps.build-profile.outputs.profile }}"
APPIMAGE_PATH="$(find "src-tauri/target/${PROFILE}/bundle/appimage" -name '*.AppImage' -print -quit 2>/dev/null || true)"
if [ -z "$APPIMAGE_PATH" ]; then
echo "No AppImage produced by this Linux build; skipping metadata verification"
exit 0
fi
APPIMAGE_PATH="$(readlink -f "$APPIMAGE_PATH")"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
(
cd "$WORKDIR"
# AppImage integration tools read the root desktop entry. appimagetool
# adds X-AppImage-Version there, not to the source copy under usr/share.
"$APPIMAGE_PATH" --appimage-extract '*.desktop' >/dev/null
DESKTOP_FILE="$(find squashfs-root -maxdepth 1 -type f -name '*.desktop' -print -quit)"
if [ -z "$DESKTOP_FILE" ]; then
echo "ERROR: AppImage desktop metadata is missing" >&2
exit 1
fi
if ! grep -Fqx 'X-AppImage-Version=${{ steps.get-version.outputs.version }}' "$DESKTOP_FILE"; then
echo "ERROR: AppImage version metadata is missing or incorrect:" >&2
grep -E '^X-AppImage-Version=' "$DESKTOP_FILE" >&2 || true
exit 1
fi
)
- name: Upload artifacts (macOS)
if: inputs.upload-artifacts && contains(inputs.platform, 'macos')
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.asset-prefix }}-${{ inputs.target }}
path: |
src-tauri/target/${{ inputs.target }}/${{ steps.build-profile.outputs.profile }}/bundle/dmg/*.dmg
src-tauri/target/${{ inputs.target }}/${{ steps.build-profile.outputs.profile }}/bundle/macos/*.app
retention-days: 30
- name: Audit Linux package runtime contents
if: contains(inputs.platform, 'ubuntu')
shell: bash
run: |
set -euo pipefail
# The runtime smoke test launches the packaged handy binary, which links
# GTK (via tao) even for the headless --list-devices path and therefore
# needs a display to start. CI runners are headless, so provide a virtual
# X server via xvfb-run.
if ! command -v xvfb-run >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y xvfb
fi
PROFILE="${{ steps.build-profile.outputs.profile }}"
BUNDLE_DIR="src-tauri/target/${PROFILE}/bundle"
require_pattern() {
local listing="$1"
local pattern="$2"
local label="$3"
if ! grep -Eq "$pattern" "$listing"; then
echo "ERROR: missing ${label} (${pattern}) in ${listing}" >&2
cat "$listing" >&2
exit 1
fi
}
require_path() {
local path="$1"
local label="$2"
if [ ! -e "$path" ]; then
echo "ERROR: missing ${label}: ${path}" >&2
find "$(dirname "$path")" -maxdepth 2 -type f 2>/dev/null >&2 || true
exit 1
fi
}
smoke_binary() {
local root="$1"
local label="$2"
local vulkan_icd="${3:-}"
# Run under a virtual X server: handy initializes GTK on startup even
# on the headless --list-devices path, which fails without a display.
# An AppImage audit supplies Mesa's software ICD so GPU registration
# is deterministic even on a runner without physical graphics.
if [ -n "$vulkan_icd" ]; then
export VK_ICD_FILENAMES="$vulkan_icd"
fi
HANDY_NO_GTK_LAYER_SHELL=1 \
LD_LIBRARY_PATH="${root}/usr/lib/Handy:${root}/usr/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" \
xvfb-run -a "${root}/usr/bin/handy" --list-devices \
>/tmp/handy-package-smoke.log 2>&1
echo "${label} --list-devices smoke passed"
}
if compgen -G "${BUNDLE_DIR}/deb/*.deb" > /dev/null; then
for deb in "${BUNDLE_DIR}"/deb/*.deb; do
echo "Auditing deb package: ${deb}"
listing="$(mktemp)"
dpkg-deb -c "$deb" > "$listing"
require_pattern "$listing" 'usr/bin/handy$' "handy binary"
require_pattern "$listing" 'usr/lib/Handy/libtranscribe\.so(\.[0-9]+)+$' "transcribe runtime library (SONAME)"
require_pattern "$listing" 'usr/lib/Handy/libggml-cpu.*\.so' "ggml CPU backend module"
# Handy's libs must stay out of the ldconfig-scanned /usr/lib
# (issue #1639): app-private libs belong in /usr/lib/Handy only.
# `[^/]+` excludes the Handy/ subdir, so only direct hits match.
if grep -Eq 'usr/lib/[^/]+\.so' "$listing"; then
echo "ERROR: libraries installed directly into /usr/lib (issue #1639):" >&2
grep -E 'usr/lib/[^/]+\.so' "$listing" >&2
exit 1
fi
root="$(mktemp -d)"
dpkg-deb -x "$deb" "$root"
smoke_binary "$root" "deb"
rm -rf "$root" "$listing"
done
fi
if compgen -G "${BUNDLE_DIR}/rpm/*.rpm" > /dev/null; then
if ! command -v rpm >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y rpm
fi
for rpm_pkg in "${BUNDLE_DIR}"/rpm/*.rpm; do
echo "Auditing rpm package: ${rpm_pkg}"
listing="$(mktemp)"
rpm -qpl "$rpm_pkg" > "$listing"
require_pattern "$listing" '/usr/bin/handy$' "handy binary"
require_pattern "$listing" '/usr/lib/Handy/libtranscribe\.so(\.[0-9]+)+$' "transcribe runtime library (SONAME)"
require_pattern "$listing" '/usr/lib/Handy/libggml-cpu.*\.so' "ggml CPU backend module"
# Same /usr/lib pollution guard as the deb audit (issue #1639).
if grep -Eq 'usr/lib/[^/]+\.so' "$listing"; then
echo "ERROR: libraries installed directly into /usr/lib (issue #1639):" >&2
grep -E 'usr/lib/[^/]+\.so' "$listing" >&2
exit 1
fi
rm -f "$listing"
done
fi
if compgen -G "${BUNDLE_DIR}/appimage/*.AppImage" > /dev/null; then
for appimage in "${BUNDLE_DIR}"/appimage/*.AppImage; do
echo "Auditing AppImage package: ${appimage}"
workdir="$(mktemp -d)"
cp "$appimage" "${workdir}/Handy.AppImage"
chmod +x "${workdir}/Handy.AppImage"
(
cd "$workdir"
./Handy.AppImage --appimage-extract >/dev/null
require_path "squashfs-root/usr/bin/handy" "AppImage handy binary"
# Gear Lever and other integration tools consume the root entry;
# appimagetool does not update the copy under usr/share/applications.
desktop_file="$(find squashfs-root -maxdepth 1 -type f -name '*.desktop' -print -quit)"
require_path "$desktop_file" "AppImage desktop metadata"
if ! grep -Fqx 'X-AppImage-Version=${{ steps.get-version.outputs.version }}' "$desktop_file"; then
echo "ERROR: AppImage version metadata is missing or incorrect:" >&2
grep -E '^X-AppImage-Version=' "$desktop_file" >&2 || true
exit 1
fi
# The SONAME (libtranscribe.so.N) is the name the loader
# resolves; the bare .so alias may or may not be present.
if ! find "squashfs-root/usr/lib" -name 'libtranscribe.so*' | grep -q .; then
echo "ERROR: missing AppImage transcribe runtime library" >&2
find "squashfs-root/usr/lib" -maxdepth 1 -type f >&2
exit 1
fi
if ! find "squashfs-root/usr/lib" -name 'libggml-cpu*.so*' | grep -q .; then
echo "ERROR: missing AppImage ggml CPU backend module" >&2
find "squashfs-root/usr/lib" -maxdepth 1 -type f >&2
exit 1
fi
require_path "squashfs-root/usr/lib/libggml-vulkan.so" "AppImage ggml Vulkan backend module"
# These libraries mediate access to the host graphics stack.
# Shipping build-host copies ahead of the system versions can
# break Vulkan ICD or Mesa loading on newer distributions.
for pattern in \
'libvulkan.so*' \
'libwayland-client.so*'; do
forbidden="$(find squashfs-root/usr -name "$pattern" -print -quit)"
if [ -n "$forbidden" ]; then
echo "ERROR: AppImage bundles host graphics loader: $forbidden" >&2
exit 1
fi
done
vulkan_icd="$(find /usr/share/vulkan/icd.d -type f -name 'lvp_icd*.json' -print -quit)"
if [ -z "$vulkan_icd" ]; then
echo "ERROR: Mesa software Vulkan ICD is unavailable for the AppImage audit" >&2
exit 1
fi
smoke_binary "$(pwd)/squashfs-root" "AppImage" "$vulkan_icd"
# llvmpipe is intentionally not exposed as a compute device by
# ggml, but reaching this log proves the module loaded against a
# usable system Vulkan loader/ICD instead of failing silently.
if ! grep -q 'load_backend: loaded Vulkan backend' /tmp/handy-package-smoke.log; then
echo "ERROR: AppImage could not initialize its Vulkan backend with a usable system ICD" >&2
cat /tmp/handy-package-smoke.log >&2
exit 1
fi
)
rm -rf "$workdir"
done
fi
- name: Upload artifacts (Linux)
if: inputs.upload-artifacts && contains(inputs.platform, 'ubuntu')
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.asset-prefix }}-${{ inputs.platform }}-${{ inputs.target }}
path: |
src-tauri/target/${{ steps.build-profile.outputs.profile }}/bundle/deb/*.deb
src-tauri/target/${{ steps.build-profile.outputs.profile }}/bundle/appimage/*.AppImage
src-tauri/target/${{ steps.build-profile.outputs.profile }}/bundle/rpm/*.rpm
retention-days: 30
# Run on Windows releases too; upload-artifacts only controls retention.
- name: Audit Windows package runtime contents
if: contains(inputs.platform, 'windows')
shell: pwsh
run: |
$profile = "${{ steps.build-profile.outputs.profile }}"
$target = "${{ inputs.target }}"
$base = "src-tauri/target"
if ($target -and $target -ne "x86_64-pc-windows-msvc") {
$bundleBase = Join-Path $base (Join-Path $target (Join-Path $profile "bundle"))
} else {
$bundleBase = Join-Path $base (Join-Path $profile "bundle")
}
# VC++ runtime staging is mandatory for every Windows build.
$stagedDlls = Get-ChildItem "src-tauri/transcribe-libs" -Filter "*.dll" -ErrorAction SilentlyContinue
if (-not $stagedDlls) {
throw "src-tauri/transcribe-libs contains no DLLs; every Windows build must stage the app-local VC++ runtime (issue #1527)"
}
# x64 also ships dynamic transcribe/ggml DLLs; ARM64 is otherwise static.
$requiredStaged = @("msvcp140.dll", "vcruntime140.dll")
if ("${{ inputs.target }}" -notlike "aarch64*") {
$requiredStaged += @("vcomp140.dll")
if (-not ($stagedDlls | Where-Object { $_.Name -like "*transcribe*" })) {
throw "Staging is missing transcribe runtime DLLs"
}
if (-not ($stagedDlls | Where-Object { $_.Name -like "*ggml*" })) {
throw "Staging is missing ggml runtime DLLs"
}
}
foreach ($name in $requiredStaged) {
if (-not ($stagedDlls | Where-Object { $_.Name -ieq $name })) {
throw "Staging is missing $name"
}
}
# Both installer types must contain every staged DLL and launch.
function Assert-PackageContents {
param([string]$Root, [string]$Label)
$handy = Get-ChildItem $Root -Filter "handy.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $handy) {
throw "$Label package is missing handy.exe"
}
foreach ($dll in $stagedDlls) {
if (-not (Get-ChildItem $Root -Filter $dll.Name -Recurse -ErrorAction SilentlyContinue)) {
throw "$Label package is missing staged runtime DLL $($dll.Name)"
}
}
$deviceOutput = (& $handy.FullName --list-devices | Out-String)
$deviceOutput | Write-Host
if ($LASTEXITCODE -ne 0) {
throw "$Label handy.exe --list-devices failed"
}
if ("${{ inputs.target }}" -like "aarch64*" -and $deviceOutput -match "kind=(vulkan|cuda|metal|gpu)") {
throw "$Label Windows ARM64 package unexpectedly exposed a GPU compute device"
}
if ("${{ inputs.target }}" -like "aarch64*" -and $deviceOutput -notmatch "kind=cpu") {
throw "$Label Windows ARM64 package did not expose its required CPU compute device"
}
}
$nsisPackages = Get-ChildItem (Join-Path $bundleBase "nsis") -Filter "*.exe" -ErrorAction SilentlyContinue
if (-not $nsisPackages) {
throw "No NSIS packages found under $bundleBase for runtime DLL audit"
}
foreach ($pkg in $nsisPackages) {
Write-Host "Auditing NSIS package: $($pkg.FullName)"
$installDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $installDir | Out-Null
$proc = Start-Process -FilePath $pkg.FullName -ArgumentList @("/S", "/PORTABLE", "/D=$installDir") -Wait -PassThru
if ($proc.ExitCode -ne 0) {
throw "Silent portable install failed for $($pkg.FullName) with exit code $($proc.ExitCode)"
}
Assert-PackageContents -Root $installDir -Label "Installed NSIS"
Remove-Item -Recurse -Force $installDir
}
# Administrative install extracts the MSI payload without elevation.
$msiPackages = Get-ChildItem (Join-Path $bundleBase "msi") -Filter "*.msi" -ErrorAction SilentlyContinue
if (-not $msiPackages) {
throw "No MSI packages found under $bundleBase for runtime DLL audit"
}
foreach ($pkg in $msiPackages) {
Write-Host "Auditing MSI package: $($pkg.FullName)"
$installDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $installDir | Out-Null
$logFile = Join-Path $installDir "msi-admin-install.log"
# Keep quoting explicit and place public properties last.
$msiArgs = "/a `"$($pkg.FullName)`" /qn /L*v `"$logFile`" TARGETDIR=`"$installDir`""
$proc = Start-Process -FilePath "msiexec.exe" -ArgumentList $msiArgs -Wait -PassThru
if ($proc.ExitCode -ne 0) {
if (Test-Path $logFile) { Get-Content $logFile | Out-Host }
throw "Administrative install failed for $($pkg.FullName) with exit code $($proc.ExitCode)"
}
Assert-PackageContents -Root $installDir -Label "Installed MSI"
Remove-Item -Recurse -Force $installDir
}
- name: Upload artifacts (Windows)
if: inputs.upload-artifacts && contains(inputs.platform, 'windows')
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.asset-prefix }}-${{ inputs.target }}
path: |
src-tauri/target/${{ inputs.target != '' && inputs.target != 'x86_64-pc-windows-msvc' && format('{0}/{1}', inputs.target, steps.build-profile.outputs.profile) || steps.build-profile.outputs.profile }}/bundle/msi/*.msi
src-tauri/target/${{ inputs.target != '' && inputs.target != 'x86_64-pc-windows-msvc' && format('{0}/{1}', inputs.target, steps.build-profile.outputs.profile) || steps.build-profile.outputs.profile }}/bundle/nsis/*.exe
retention-days: 30