@@ -136,6 +136,219 @@ jobs:
136136 path : whisper-bin-ubuntu-${{ matrix.build }}.tar.gz
137137 name : whisper-bin-ubuntu-${{ matrix.build }}.tar.gz
138138
139+ macos-cpu :
140+ runs-on : ${{ matrix.os }}
141+ needs : determine-tag
142+ if : ${{ needs.determine-tag.outputs.should_release == 'true' }}
143+
144+ strategy :
145+ matrix :
146+ include :
147+ - build : arm64
148+ os : macos-26
149+ defines : " -DGGML_METAL_EMBED_LIBRARY=ON"
150+ # macos-15-intel is the last x86_64 image available on Actions, and it is
151+ # scheduled to go away in August 2027 (actions/runner-images#13046).
152+ # Metal is off here because the hosted Intel runners have no GPU, which is
153+ # the same reason ggml-org/llama.cpp disables it for its x64 macOS build.
154+ - build : x64
155+ os : macos-15-intel
156+ defines : " -DGGML_METAL=OFF"
157+
158+ steps :
159+ - name : Clone
160+ uses : actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
161+
162+ - name : ccache
163+ uses : ggml-org/ccache-action@v1.2.21
164+ with :
165+ key : release-${{ matrix.os }}-cpu
166+ evict-old-files : 1d
167+
168+ # Same shape as ubuntu-cpu: backends built as loadable dylibs, one CPU backend per
169+ # micro-architecture, selected at load time. GGML_NATIVE has to be off for
170+ # GGML_CPU_ALL_VARIANTS to drive the per-variant flags at all; left on, it would
171+ # instead compile a single backend for whichever CPU the runner happened to have,
172+ # which on a refreshed arm64 image means -mcpu=native+i8mm+sme and a binary that
173+ # will not start on an M1. The Apple variants are apple_m1 (dotprod),
174+ # apple_m2_m3 (+i8mm) and apple_m4 (+sme), so one archive covers every Mac
175+ # without giving up the newer instructions on the machines that have them.
176+ - name : Build
177+ run : |
178+ cmake -B build \
179+ -DCMAKE_BUILD_TYPE=Release \
180+ -DCMAKE_INSTALL_RPATH='@loader_path' \
181+ -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \
182+ -DCMAKE_OSX_DEPLOYMENT_TARGET=13.3 \
183+ -DGGML_BACKEND_DL=ON \
184+ -DGGML_CPU_ALL_VARIANTS=ON \
185+ -DGGML_NATIVE=OFF \
186+ -DWHISPER_BUILD_IS_DEV=${{ env.WHISPER_BUILD_IS_DEV }} \
187+ ${{ matrix.defines }}
188+ cmake --build build --config Release -j $(sysctl -n hw.logicalcpu)
189+
190+ - name : Pack artifacts
191+ run : |
192+ cp LICENSE ./build/bin/
193+ tar -czvf whisper-bin-macos-${{ matrix.build }}.tar.gz \
194+ -s ",^\.,whisper-bin-macos-${{ matrix.build }}," \
195+ -C ./build/bin .
196+
197+ # Runs from an extracted copy of the archive rather than from build/bin, so the
198+ # test covers what actually ships: @loader_path resolving the dylibs next to the
199+ # binary once the tree has moved off the machine that built it.
200+ - name : Smoke test
201+ run : |
202+ mkdir -p "${RUNNER_TEMP}/unpacked"
203+ tar -xzf whisper-bin-macos-${{ matrix.build }}.tar.gz \
204+ -C "${RUNNER_TEMP}/unpacked" --strip-components 1
205+ "${RUNNER_TEMP}/unpacked/whisper-cli" \
206+ -m models/for-tests-ggml-base.en.bin \
207+ -f samples/jfk.wav
208+
209+ - name : Upload artifacts
210+ uses : actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
211+ with :
212+ path : whisper-bin-macos-${{ matrix.build }}.tar.gz
213+ name : whisper-bin-macos-${{ matrix.build }}.tar.gz
214+
215+ # Fuses the two thin builds above into one universal tree. Everything that exists on
216+ # both sides is lipo'd. The CPU and Metal backends are built per micro-architecture and
217+ # so only ever exist on one side; those are copied through thin, which is what
218+ # GGML_BACKEND_DL expects - the loader walks every libggml-*.so next to the binary
219+ # and skips the ones it cannot load, so the backends belonging to the other
220+ # architecture are simply never used. Anything else that is one-sided is a packaging
221+ # bug and fails the job rather than disappearing from the archive silently.
222+ macos-universal :
223+ runs-on : macos-26
224+ needs :
225+ - determine-tag
226+ - macos-cpu
227+ if : ${{ needs.determine-tag.outputs.should_release == 'true' }}
228+
229+ steps :
230+ - name : Clone
231+ uses : actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
232+
233+ - name : Download arm64 artifact
234+ uses : actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
235+ with :
236+ name : whisper-bin-macos-arm64.tar.gz
237+ path : ./thin
238+
239+ - name : Download x64 artifact
240+ uses : actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
241+ with :
242+ name : whisper-bin-macos-x64.tar.gz
243+ path : ./thin
244+
245+ - name : Unpack
246+ run : |
247+ mkdir -p arm64 x64
248+ tar -xzf ./thin/whisper-bin-macos-arm64.tar.gz -C arm64 --strip-components 1
249+ tar -xzf ./thin/whisper-bin-macos-x64.tar.gz -C x64 --strip-components 1
250+
251+ (cd arm64 && find . \( -type f -o -type l \) | sed 's,^\./,,') | sort > "${RUNNER_TEMP}/arm64.list"
252+ (cd x64 && find . \( -type f -o -type l \) | sed 's,^\./,,') | sort > "${RUNNER_TEMP}/x64.list"
253+ sort -u "${RUNNER_TEMP}/arm64.list" "${RUNNER_TEMP}/x64.list" > "${RUNNER_TEMP}/all.list"
254+
255+ - name : Fuse
256+ run : |
257+ # GGML_BACKEND_DL emits loadable backends as libggml-*.so, one per
258+ # micro-architecture, so they have no counterpart in the other tree.
259+ # Everything else has to be present on both sides.
260+ is_backend() {
261+ case "$1" in
262+ libggml-*.so) return 0 ;;
263+ *) return 1 ;;
264+ esac
265+ }
266+
267+ exists() { [ -e "$1" ] || [ -L "$1" ]; }
268+
269+ rc=0
270+ while IFS= read -r f; do
271+ mkdir -p "universal/$(dirname "$f")"
272+
273+ if exists "arm64/$f" && exists "x64/$f"; then
274+ if [ -L "arm64/$f" ]; then
275+ cp -a "arm64/$f" "universal/$f"
276+ elif file -b "arm64/$f" | grep -q "Mach-O"; then
277+ lipo -create "arm64/$f" "x64/$f" -output "universal/$f"
278+ else
279+ cp -a "arm64/$f" "universal/$f"
280+ fi
281+ elif is_backend "$(basename "$f")"; then
282+ if exists "arm64/$f"; then
283+ cp -a "arm64/$f" "universal/$f"
284+ else
285+ cp -a "x64/$f" "universal/$f"
286+ fi
287+ else
288+ echo "::error::${f} exists for only one architecture and is not a backend"
289+ rc=1
290+ fi
291+ done < "${RUNNER_TEMP}/all.list"
292+
293+ exit $rc
294+
295+ # lipo -archs rather than parsing lipo -info, so the check does not depend on the
296+ # order the architectures happen to be listed in.
297+ - name : Verify
298+ run : |
299+ rc=0
300+ while IFS= read -r f; do
301+ if ! { [ -e "universal/$f" ] || [ -L "universal/$f" ]; }; then
302+ echo "::error::${f} is missing from the fused tree"
303+ rc=1
304+ continue
305+ fi
306+
307+ [ -L "universal/$f" ] && continue
308+ file -b "universal/$f" | grep -q "Mach-O" || continue
309+
310+ archs="$(lipo -archs "universal/$f")"
311+ case "$(basename "$f")" in
312+ libggml-*.so)
313+ printf ' backend %-44s %s\n' "$f" "$archs"
314+ ;;
315+ *)
316+ if [[ " ${archs} " == *" arm64 "* && " ${archs} " == *" x86_64 "* ]]; then
317+ printf ' universal %-44s %s\n' "$f" "$archs"
318+ else
319+ echo "::error::${f} is not universal (${archs})"
320+ rc=1
321+ fi
322+ ;;
323+ esac
324+ done < "${RUNNER_TEMP}/all.list"
325+
326+ exit $rc
327+
328+ - name : Pack artifacts
329+ run : |
330+ tar -czvf whisper-bin-macos-universal.tar.gz \
331+ -s ",^\.,whisper-bin-macos-universal," \
332+ -C ./universal .
333+
334+ # Only the arm64 slice can be exercised here, since the hosted arm64 runners do not
335+ # have Rosetta. The x86_64 slice is the same build the x64 job above already ran
336+ # natively, so it is covered there.
337+ - name : Smoke test
338+ run : |
339+ mkdir -p "${RUNNER_TEMP}/unpacked"
340+ tar -xzf whisper-bin-macos-universal.tar.gz \
341+ -C "${RUNNER_TEMP}/unpacked" --strip-components 1
342+ "${RUNNER_TEMP}/unpacked/whisper-cli" \
343+ -m models/for-tests-ggml-base.en.bin \
344+ -f samples/jfk.wav
345+
346+ - name : Upload artifacts
347+ uses : actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
348+ with :
349+ path : whisper-bin-macos-universal.tar.gz
350+ name : whisper-bin-macos-universal.tar.gz
351+
139352 windows :
140353 runs-on : windows-latest
141354 needs : determine-tag
@@ -596,6 +809,8 @@ jobs:
596809 needs :
597810 - determine-tag
598811 - ubuntu-cpu
812+ - macos-cpu
813+ - macos-universal
599814 - ios-xcode-build
600815 - windows
601816 - windows-blas
@@ -629,6 +844,17 @@ jobs:
629844 with :
630845 subject-path : ' release/*'
631846
847+ # One manifest for the whole release rather than a .sha256 next to each archive:
848+ # it adds a single asset instead of one per artifact, and it can be checked in one
849+ # go with `shasum -a 256 -c SHA256SUMS`. Written after the attestation so the
850+ # attested subjects stay exactly the artifacts themselves.
851+ - name : Checksum release artifacts
852+ run : |
853+ cd ./release
854+ sha256sum * > "${RUNNER_TEMP}/SHA256SUMS"
855+ mv "${RUNNER_TEMP}/SHA256SUMS" .
856+ cat SHA256SUMS
857+
632858 - name : Create and push git tag
633859 run : |
634860 TAG="${{ needs.determine-tag.outputs.tag_name }}"
@@ -658,7 +884,7 @@ jobs:
658884 const fs = require('fs');
659885 const release_id = '${{ steps.create_release.outputs.id }}';
660886 for (let file of await fs.readdirSync('./release')) {
661- if (path.extname(file) === '.zip' || file.endsWith('.tar.gz')) {
887+ if (path.extname(file) === '.zip' || file.endsWith('.tar.gz') || file === 'SHA256SUMS' ) {
662888 console.log('uploadReleaseAsset', file);
663889 await github.repos.uploadReleaseAsset({
664890 owner: context.repo.owner,
0 commit comments