diff --git a/.github/scripts/stage-linux-whisper.sh b/.github/scripts/stage-linux-whisper.sh new file mode 100755 index 0000000..4c4e3e7 --- /dev/null +++ b/.github/scripts/stage-linux-whisper.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# +# Stage a Linux whisper.cpp build into ./staging for packaging. +# +# Shared by the linux-vulkan and linux-cuda jobs of +# build-whispercpp-release.yml. Run from the whisper.cpp build root (the +# directory holding ./build). +# +# Why this is not just `cp build/bin/libggml*.so staging/` — that was the +# original one-liner, and it shipped three separate defects for four +# releases (SubtitleEdit issue #13680): +# +# 1. The glob never matched libwhisper.so*, so the library whisper-cli is +# linked against was simply absent from the archive. +# 2. cmake leaves a symlink chain in build/bin +# (libggml.so -> libggml.so.0 -> libggml.so.0.9.4). A bare `cp` +# dereferences those, so the file landed under the *unversioned* name +# while its SONAME — and whisper-cli's DT_NEEDED entry — said +# libggml.so.0. The loader looks up the SONAME, so it never matched. +# 3. The RUNPATH cmake bakes in points at the CI build directory +# (/home/runner/work/...), which does not exist on a user's machine, +# and the loader does not search the working directory. +# +# Shipping the symlink chain as-is would not fix (2): Subtitle Edit unpacks +# these archives with .NET's ZipArchive, whose zip path writes a symlink +# entry out as an ordinary file containing the target path. So every library +# is staged as a real file named after its SONAME. + +set -euo pipefail + +# build/bin is where whisper.cpp puts both binaries and libraries; older / +# differently-configured trees split them, so accept the alternatives too. +lib_dirs=(build/bin build/lib build/src build/ggml/src) + +mkdir -p staging +cp build/bin/whisper-cli staging/ + +soname_of() { + # Empty output when the ELF has no DT_SONAME (true of the dlopen'd ggml + # backend plugins: libggml-vulkan.so, libggml-cuda.so, libggml-cpu-*.so). + readelf -d "$1" 2>/dev/null | sed -n 's/.*SONAME.*\[\(.*\)\].*/\1/p' +} + +shopt -s nullglob +staged=0 +for dir in "${lib_dirs[@]}"; do + for src in "$dir"/libwhisper.so* "$dir"/libggml*.so*; do + # Collapse the symlink chain to the one real ELF behind it, so each + # library is staged exactly once no matter how many aliases point at it. + real=$(readlink -f "$src") + [ -f "$real" ] || continue + + soname=$(soname_of "$real") + name=${soname:-$(basename "$real")} + + if [ ! -e "staging/$name" ]; then + cp -L "$real" "staging/$name" + staged=$((staged + 1)) + fi + done +done + +if [ "$staged" -eq 0 ]; then + echo "::error::No whisper/ggml shared libraries found under: ${lib_dirs[*]}" + exit 1 +fi + +# Point every ELF at its own directory. patchelf writes DT_RUNPATH, which is +# NOT inherited by transitive dependencies — libggml.so.0 does not benefit +# from whisper-cli's RUNPATH when it looks for libggml-base.so.0 — so the +# libraries need this just as much as the executable does. +for f in staging/whisper-cli staging/*.so*; do + patchelf --set-rpath '$ORIGIN' "$f" +done + +echo "Staged $staged shared libraries:" +ls -la staging +echo +echo "whisper-cli dynamic section:" +readelf -d staging/whisper-cli | grep -E 'NEEDED|RUNPATH|RPATH' diff --git a/.github/scripts/verify-linux-whisper.sh b/.github/scripts/verify-linux-whisper.sh new file mode 100755 index 0000000..24724d1 --- /dev/null +++ b/.github/scripts/verify-linux-whisper.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Regression guard for the Linux whisper.cpp archives (SubtitleEdit #13680). +# +# whispercpp-184, -186 and -191 all shipped a whisper-cli that dies on +# startup with "error while loading shared libraries: libwhisper.so.1" +# because the packaging step never copied the library. Nothing in the build +# noticed, because the build machine had the libraries sitting in build/bin +# the whole time. +# +# So verify the way a user's machine sees it: copy the staged payload to a +# fresh location, run the loader from an unrelated working directory with an +# empty LD_LIBRARY_PATH, and require every bundled library to resolve on the +# strength of the $ORIGIN RPATH alone. +# +# Libraries we deliberately do NOT ship stay out of scope: libvulkan.so.1 +# comes from the user's GPU driver, and libcuda/libcudart/libcublas come +# from their CUDA install. Only lib{whisper,ggml}* must resolve from within +# the archive. + +set -euo pipefail + +verify_dir=$(mktemp -d) +cp -a staging/. "$verify_dir/" + +# Anywhere except the library directory — if the working directory were on +# the search path, a broken RPATH would still look fine here. +cd / + +status=0 + +for f in "$verify_dir"/*; do + # staging holds only ELF files at this point (the silero .bin model is + # added after this step), but skip anything else defensively. + readelf -h "$f" >/dev/null 2>&1 || continue + + name=$(basename "$f") + out=$(LD_LIBRARY_PATH= ldd "$f" 2>/dev/null || true) + + missing=$(echo "$out" | awk '/not found/ {print $1}' | grep -E '^lib(whisper|ggml)' || true) + if [ -n "$missing" ]; then + echo "::error::$name cannot resolve bundled libraries: $(echo "$missing" | tr '\n' ' ')" + echo "$out" + status=1 + fi +done + +# whisper-cli is the one the user actually launches, and an empty NEEDED +# list would sail through the loop above. Assert it really did bind to the +# bundled libraries rather than to nothing at all. +cli_out=$(LD_LIBRARY_PATH= ldd "$verify_dir/whisper-cli" 2>/dev/null || true) +for required in libwhisper.so libggml.so; do + if ! echo "$cli_out" | grep -q "$required"; then + echo "::error::whisper-cli does not link against $required — check the build configuration" + status=1 + fi +done + +echo "whisper-cli resolved dependencies (LD_LIBRARY_PATH cleared, cwd=/):" +echo "$cli_out" + +rm -rf "$verify_dir" + +if [ "$status" -ne 0 ]; then + echo "::error::Linux archive would ship broken — refusing to publish." + exit 1 +fi + +echo "OK: every bundled whisper/ggml library resolves via \$ORIGIN." diff --git a/.github/workflows/build-whispercpp-release.yml b/.github/workflows/build-whispercpp-release.yml index 196bacf..3dd3a26 100644 --- a/.github/workflows/build-whispercpp-release.yml +++ b/.github/workflows/build-whispercpp-release.yml @@ -17,6 +17,18 @@ name: Build whisper.cpp release zips # whisper-cuda-linux64.zip, which intentionally does NOT include the VAD # model (also matches the v184 manifest). # +# Linux packaging (see SubtitleEdit issue #13680): the two Linux jobs build +# with BUILD_SHARED_LIBS=ON, so whisper-cli is only half the payload — it +# links against libwhisper.so.1 and libggml.so.0 at runtime. Those must be +# staged under the SONAME the loader asks for, as REAL files rather than the +# symlink chain cmake leaves in build/bin, because Subtitle Edit unpacks +# these archives with .NET's ZipArchive, which writes a symlink entry out as +# an ordinary file containing the target path. Every ELF also gets its RPATH +# rewritten to $ORIGIN so the loader looks next to the binary; the build-dir +# RUNPATH cmake bakes in is meaningless on a user's machine. The `Verify` +# step is the regression guard — whispercpp-184/186/191 all shipped Linux +# archives with no libwhisper.so at all and nothing caught it. +# # Dispatch manually with workflow_dispatch. Defaults target v1.9.1; bump # `whisper_ref` / `release_tag` for future releases. @@ -30,11 +42,11 @@ on: release_tag: description: 'Tag for the resulting support-files release' required: true - default: 'whispercpp-191' + default: 'whispercpp-191-r2' release_name: description: 'Display name for the release' required: true - default: 'Whisper CPP v1.9.1' + default: 'Whisper CPP v1.9.1 (r2 - Linux shared library fix)' silero_url: description: 'URL of ggml-silero-v6.2.0.zip (the VAD model bundled into 4 of the 5 archives)' required: true @@ -186,6 +198,14 @@ jobs: repository: ggml-org/whisper.cpp ref: ${{ inputs.whisper_ref }} + # Second, into a subfolder, for the shared Linux staging/verify scripts. + # Must come after the whisper.cpp checkout — that one owns the workspace + # root and cleans it. + - name: Check out support-files scripts + uses: actions/checkout@v5 + with: + path: .se-support + - name: Install Vulkan SDK + tools # ubuntu-latest is now noble (24.04), so use the noble repo path — # the jammy variant pulls libyaml-cpp0.7 which isn't in noble. @@ -194,7 +214,7 @@ jobs: sudo wget -qO /etc/apt/sources.list.d/lunarg-vulkan-noble.list \ https://packages.lunarg.com/vulkan/lunarg-vulkan-noble.list sudo apt-get update - sudo apt-get install -y vulkan-sdk libvulkan-dev glslang-tools libsdl2-dev cmake + sudo apt-get install -y vulkan-sdk libvulkan-dev glslang-tools libsdl2-dev cmake patchelf binutils - name: Configure # GGML_BACKEND_DL is required by GGML_CPU_ALL_VARIANTS — it switches @@ -214,11 +234,10 @@ jobs: run: cmake --build build --config Release --target whisper-cli -j $(nproc) - name: Stage release bits - run: | - mkdir staging - cp build/bin/whisper-cli staging/ - cp build/bin/libggml*.so staging/ 2>/dev/null || cp build/lib/libggml*.so staging/ - ls -la staging + run: bash .se-support/.github/scripts/stage-linux-whisper.sh + + - name: Verify bundled libraries resolve + run: bash .se-support/.github/scripts/verify-linux-whisper.sh - uses: actions/download-artifact@v4 with: { name: silero, path: staging } @@ -251,6 +270,12 @@ jobs: repository: ggml-org/whisper.cpp ref: ${{ inputs.whisper_ref }} + # See the note in linux-vulkan: must follow the whisper.cpp checkout. + - name: Check out support-files scripts + uses: actions/checkout@v5 + with: + path: .se-support + - name: Install CUDA toolkit 12.4 uses: Jimver/cuda-toolkit@v0.2.19 with: @@ -258,7 +283,7 @@ jobs: method: network - name: Install build deps - run: sudo apt-get update && sudo apt-get install -y cmake build-essential + run: sudo apt-get update && sudo apt-get install -y cmake build-essential patchelf binutils - name: Configure # GGML_BACKEND_DL is required by GGML_CPU_ALL_VARIANTS — see notes in @@ -277,11 +302,10 @@ jobs: run: cmake --build build --config Release --target whisper-cli -j $(nproc) - name: Stage release bits (no silero in this zip per v184 layout) - run: | - mkdir staging - cp build/bin/whisper-cli staging/ - cp build/bin/libggml*.so staging/ 2>/dev/null || cp build/lib/libggml*.so staging/ - ls -la staging + run: bash .se-support/.github/scripts/stage-linux-whisper.sh + + - name: Verify bundled libraries resolve + run: bash .se-support/.github/scripts/verify-linux-whisper.sh - name: Pack whisper-cuda-linux64.zip run: |