Skip to content

Unsloth whisper prebuilt (full release) #8

Unsloth whisper prebuilt (full release)

Unsloth whisper prebuilt (full release) #8

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
name: Unsloth whisper prebuilt (full release)
# One workflow run, one release per upstream whisper.cpp tag. The heavy build
# work is split into reusable per-accelerator children:
# unsloth-prebuilt-cpu.yml -- CPU bundles (Linux x64/arm64, Windows x64 [P0] + arm64 [best-effort], macOS x64)
# unsloth-prebuilt-macos.yml -- macOS arm64 Metal (+ x64 CPU slice) [P0]
# unsloth-prebuilt-cuda.yml -- Linux CUDA bundles (x64 + arm64, matrix profiles) [P1]
# unsloth-prebuilt-cuda-windows.yml -- Windows CUDA bundles (x64, matrix profiles) [P1]
# unsloth-prebuilt-vulkan.yml -- Vulkan bundles (Linux + Windows, x64) [P1]
# unsloth-prebuilt-rocm.yml -- ROCm bundles (Linux + Windows, per gfx target) [P1]
#
# Tiered atomicity: the `assemble` job hard-requires the P0 children (CPU +
# macOS) and only best-effort waits on the P1 GPU children. A P1 failure does
# NOT block the release -- its bundles are simply absent from the manifest and
# those hosts fall back to a CPU bundle. A P0 failure blocks publication
# entirely: the installer needs the full P0 set at one tag.
#
# Immutability: `resolve` dereferences the requested upstream tag to an exact
# commit SHA and records it. Every child builds that one stamped source tree
# (uploaded once as a workflow artifact), so a tag re-point upstream cannot
# change what a given packaging tag shipped.
on:
# Auto-stream from mainline whisper.cpp: every 5 days at ~3PM San Francisco
# (22:41 UTC; a fixed offset, not DST-adjusted, so ~3:41PM PDT / ~2:41PM PST).
# `*/5` fires on days 1,6,11,16,21,26,31, so the spacing resets at each month
# boundary -- close enough given upstream tags land weeks apart. A scheduled
# run resolves the newest aged upstream release and publishes it; if that
# release is already published the run is a cheap no-op (resolve sets
# exists=true and every build/assemble job is skipped). Manual dispatch can
# still build or publish any tag on demand.
schedule:
- cron: '41 22 */5 * *'
workflow_dispatch:
inputs:
upstream_tag:
description: 'Upstream ggml-org/whisper.cpp tag to build, or "latest" to resolve the newest aged release'
default: 'latest'
required: false
type: string
packaging_suffix:
description: 'Packaging suffix appended to the tag: <upstream_tag>-<suffix>'
default: 'unsloth.1'
required: true
type: string
min_age_hours:
description: 'For "latest": only build an upstream release public for at least this many hours (blank = 6)'
default: ''
required: false
type: string
publish:
description: 'Publish a GitHub Release (off = build + upload artifacts only). Scheduled runs always publish.'
default: false
required: false
type: boolean
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.inputs.upstream_tag || 'scheduled' }}-${{ github.event.inputs.packaging_suffix || 'auto' }}
cancel-in-progress: false
jobs:
resolve:
name: Resolve tag + stamp source
runs-on: ubuntu-22.04
permissions:
contents: read
outputs:
upstream_tag: ${{ steps.r.outputs.upstream_tag }}
tag: ${{ steps.r.outputs.tag }}
commit: ${{ steps.r.outputs.commit }}
repo: ${{ steps.r.outputs.repo }}
source_artifact: ${{ steps.r.outputs.source_artifact }}
exists: ${{ steps.r.outputs.exists }}
cuda_matrix: ${{ steps.r.outputs.cuda_matrix }}
win_cuda_matrix: ${{ steps.r.outputs.win_cuda_matrix }}
rocm_matrix: ${{ steps.r.outputs.rocm_matrix }}
macos_matrix: ${{ steps.r.outputs.macos_matrix }}
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Resolve immutable commit + stamp source tree
id: r
run: |
set -euo pipefail
UT='${{ github.event.inputs.upstream_tag || 'latest' }}'
SUFFIX='${{ github.event.inputs.packaging_suffix || 'unsloth.1' }}'
AGE_H='${{ github.event.inputs.min_age_hours }}'
[ -n "$AGE_H" ] || AGE_H=6
# Resolve "latest" (the default, and what every scheduled run uses) to
# the newest published upstream release that has been public for >=
# AGE_H hours -- a supply-chain aging window so a yanked or malicious
# upstream tag is not shipped the instant it appears. GitHub does not
# guarantee list order, so pick the max by published_at explicitly. An
# explicit vX.Y.Z tag (manual dispatch) skips the filter.
if [ "$UT" = "latest" ]; then
CUTOFF="$(date -u -d "-${AGE_H} hours" +%s)"
UT="$(gh api 'repos/ggml-org/whisper.cpp/releases?per_page=100' \
--jq "[.[] | select(.draft==false and .prerelease==false) | select((.published_at|fromdateiso8601) <= ${CUTOFF})] | max_by(.published_at|fromdateiso8601) | .tag_name")"
if [ -z "$UT" ] || [ "$UT" = "null" ]; then
echo "refusing: no ggml-org/whisper.cpp release older than ${AGE_H}h in the last 100 releases" >&2
exit 1
fi
echo "selected upstream ${UT} (aged >= ${AGE_H}h)"
fi
printf '%s' "$UT" | grep -qE '^v[0-9]+\.[0-9]+(\.[0-9]+)?$' \
|| { echo "refusing non-release upstream tag '$UT' (want vMAJOR.MINOR[.PATCH])" >&2; exit 1; }
printf '%s' "$SUFFIX" | grep -qE '^[A-Za-z0-9._-]+$' \
|| { echo "refusing packaging suffix '$SUFFIX'" >&2; exit 1; }
TAG="${UT}-${SUFFIX}"
REPO="$GITHUB_REPOSITORY"
# Dereference the tag to an exact commit (annotated tags resolve to the
# tag object first, then to the commit it points at).
REF_JSON="$(gh api "repos/ggml-org/whisper.cpp/git/ref/tags/${UT}")"
OBJ_TYPE="$(jq -r '.object.type' <<<"$REF_JSON")"
OBJ_SHA="$(jq -r '.object.sha' <<<"$REF_JSON")"
if [ "$OBJ_TYPE" = "tag" ]; then
COMMIT="$(gh api "repos/ggml-org/whisper.cpp/git/tags/${OBJ_SHA}" --jq '.object.sha')"
else
COMMIT="$OBJ_SHA"
fi
printf '%s' "$COMMIT" | grep -qE '^[0-9a-f]{40}$' \
|| { echo "could not resolve ${UT} to a commit" >&2; exit 1; }
echo "resolved ${UT} -> ${COMMIT}"
EXISTS=false
if [ "$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq .isDraft 2>/dev/null || true)" = "false" ]; then
EXISTS=true
fi
# Check out the exact commit and stamp cmake/build-info.cmake so the
# server reports a real version (the tarball ships without .git, where
# build-info falls back to BUILD_NUMBER 0 / "unknown"). Unlike
# llama.cpp, whisper.cpp does NOT compile BUILD_TARGET into whisper-server
# (it only feeds BUILD_NUMBER/COMMIT into install-time cmake config
# files), so the Unsloth fingerprint cannot be baked into the binary
# here; package_bundle.py carries it in every bundle's BUILD_INFO.txt,
# which the assemble verify gate then checks.
git init -q whisper-src
cd whisper-src
git remote add origin https://github.com/ggml-org/whisper.cpp.git
git fetch -q --depth 1 origin "$COMMIT"
git checkout -q --detach FETCH_HEAD
COUNT="$(git rev-list --count HEAD)"
SHORT="$(git rev-parse --short HEAD)"
sed -i "s/^set(BUILD_NUMBER 0)$/set(BUILD_NUMBER ${COUNT})/" cmake/build-info.cmake
sed -i "s/^set(BUILD_COMMIT \"unknown\")$/set(BUILD_COMMIT \"${SHORT}\")/" cmake/build-info.cmake
grep -q "set(BUILD_NUMBER ${COUNT})" cmake/build-info.cmake \
&& grep -q "set(BUILD_COMMIT \"${SHORT}\")" cmake/build-info.cmake \
|| { echo "cmake/build-info.cmake lost its expected fallback lines" >&2; exit 1; }
cd ..
tar -czf "${RUNNER_TEMP}/whisper.cpp-source-${TAG}.tar.gz" \
--exclude-vcs --transform "s,^whisper-src,whisper.cpp-${TAG}," whisper-src
# x64 CUDA profiles (mirror unslothai/llama.cpp coverage names) + one
# arm64 cuda13-portable slice. cuda12 via Jimver; cuda13 via NVIDIA
# redist (Jimver lacks 13.3).
ALL='[
{"profile":"cuda12-legacy", "arch":"x64", "runner":"ubuntu-22.04", "cuda":"12.8.0","toolkit_line":"12.8","line":"cuda12","klass":"legacy", "archs":"50-virtual 61-virtual","sms":"50 52 60 61"},
{"profile":"cuda12-older", "arch":"x64", "runner":"ubuntu-22.04", "cuda":"12.8.0","toolkit_line":"12.8","line":"cuda12","klass":"older", "archs":"70 75 80 86 89","sms":"70 75 80 86 89"},
{"profile":"cuda12-newer", "arch":"x64", "runner":"ubuntu-22.04", "cuda":"12.8.0","toolkit_line":"12.8","line":"cuda12","klass":"newer", "archs":"86 89 90 100 120","sms":"86 89 90 100 120"},
{"profile":"cuda12-portable","arch":"x64", "runner":"ubuntu-22.04", "cuda":"12.8.0","toolkit_line":"12.8","line":"cuda12","klass":"portable","archs":"70 75 80 86 89 90 100 120","sms":"70 75 80 86 89 90 100 120"},
{"profile":"cuda13-older", "arch":"x64", "runner":"ubuntu-22.04", "cuda":"13.3","toolkit_line":"13.3","line":"cuda13","klass":"older", "archs":"75 80 86 89","sms":"75 80 86 89"},
{"profile":"cuda13-newer", "arch":"x64", "runner":"ubuntu-22.04", "cuda":"13.3","toolkit_line":"13.3","line":"cuda13","klass":"newer", "archs":"86 89 90 100 120","sms":"86 89 90 100 120"},
{"profile":"cuda13-portable","arch":"x64", "runner":"ubuntu-22.04", "cuda":"13.3","toolkit_line":"13.3","line":"cuda13","klass":"portable","archs":"75 80 86 89 90 100 120","sms":"75 80 86 89 90 100 120"},
{"profile":"cuda13-portable","arch":"arm64","runner":"ubuntu-24.04-arm","cuda":"13.3","toolkit_line":"13.3","line":"cuda13","klass":"portable","archs":"90 100 120 121","sms":"90 100 120 121"}
]'
CUDA_INCLUDE="$(jq -c . <<<"$ALL")"
WIN_CUDA_INCLUDE="$(jq -c '[.[] | select(.arch=="x64") | .runner="windows-2022"]' <<<"$CUDA_INCLUDE")"
ROCM_MATRIX='{"gfx_target":["gfx1151","gfx1150","gfx120X","gfx110X","gfx103X","gfx90a","gfx908"]}'
MACOS_INCLUDE='[
{"build":"arm64","runner":"macos-26", "expect_arch":"arm64", "deploy_target":"14.0","accel":"metal","backend":"metal","defines":"-DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DGGML_METAL_USE_BF16=ON","shared":"OFF","static":"true"},
{"build":"x64", "runner":"macos-15-intel","expect_arch":"x86_64","deploy_target":"13.3","accel":"cpu", "backend":"cpu", "defines":"-DGGML_METAL=OFF -DGGML_BACKEND_DL=ON -DGGML_CPU_ALL_VARIANTS=ON","shared":"ON","static":"false"}
]'
MACOS_INCLUDE="$(jq -c . <<<"$MACOS_INCLUDE")"
{
echo "upstream_tag=$UT"
echo "tag=$TAG"
echo "commit=$COMMIT"
echo "repo=$REPO"
echo "source_artifact=whisper-source-${TAG}"
echo "exists=$EXISTS"
echo "cuda_matrix={\"include\":$CUDA_INCLUDE}"
echo "win_cuda_matrix={\"include\":$WIN_CUDA_INCLUDE}"
echo "rocm_matrix=$ROCM_MATRIX"
echo "macos_matrix={\"include\":$MACOS_INCLUDE}"
} >> "$GITHUB_OUTPUT"
echo "Resolved $UT -> $TAG ($COMMIT); release exists=$EXISTS"
- name: Upload stamped source artifact
if: ${{ steps.r.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }}
uses: actions/upload-artifact@v6
with:
name: ${{ steps.r.outputs.source_artifact }}
path: ${{ runner.temp }}/whisper.cpp-source-${{ steps.r.outputs.tag }}.tar.gz
if-no-files-found: error
retention-days: 7
build-cpu:
name: CPU
needs: resolve
if: ${{ needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/unsloth-prebuilt-cpu.yml
with:
tag: ${{ needs.resolve.outputs.tag }}
upstream_tag: ${{ needs.resolve.outputs.upstream_tag }}
repo: ${{ needs.resolve.outputs.repo }}
commit: ${{ needs.resolve.outputs.commit }}
source_artifact: ${{ needs.resolve.outputs.source_artifact }}
build-macos:
name: macOS
needs: resolve
if: ${{ needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/unsloth-prebuilt-macos.yml
with:
tag: ${{ needs.resolve.outputs.tag }}
upstream_tag: ${{ needs.resolve.outputs.upstream_tag }}
repo: ${{ needs.resolve.outputs.repo }}
commit: ${{ needs.resolve.outputs.commit }}
source_artifact: ${{ needs.resolve.outputs.source_artifact }}
matrix: ${{ needs.resolve.outputs.macos_matrix }}
build-cuda:
name: CUDA
needs: resolve
if: ${{ needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/unsloth-prebuilt-cuda.yml
with:
tag: ${{ needs.resolve.outputs.tag }}
upstream_tag: ${{ needs.resolve.outputs.upstream_tag }}
repo: ${{ needs.resolve.outputs.repo }}
commit: ${{ needs.resolve.outputs.commit }}
source_artifact: ${{ needs.resolve.outputs.source_artifact }}
matrix: ${{ needs.resolve.outputs.cuda_matrix }}
build-windows-cuda:
name: CUDA Windows
needs: resolve
if: ${{ needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/unsloth-prebuilt-cuda-windows.yml
with:
tag: ${{ needs.resolve.outputs.tag }}
upstream_tag: ${{ needs.resolve.outputs.upstream_tag }}
repo: ${{ needs.resolve.outputs.repo }}
commit: ${{ needs.resolve.outputs.commit }}
source_artifact: ${{ needs.resolve.outputs.source_artifact }}
matrix: ${{ needs.resolve.outputs.win_cuda_matrix }}
build-vulkan:
name: Vulkan
needs: resolve
if: ${{ needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/unsloth-prebuilt-vulkan.yml
with:
tag: ${{ needs.resolve.outputs.tag }}
upstream_tag: ${{ needs.resolve.outputs.upstream_tag }}
repo: ${{ needs.resolve.outputs.repo }}
commit: ${{ needs.resolve.outputs.commit }}
source_artifact: ${{ needs.resolve.outputs.source_artifact }}
build-rocm:
name: ROCm
needs: resolve
if: ${{ needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }}
uses: ./.github/workflows/unsloth-prebuilt-rocm.yml
with:
tag: ${{ needs.resolve.outputs.tag }}
upstream_tag: ${{ needs.resolve.outputs.upstream_tag }}
repo: ${{ needs.resolve.outputs.repo }}
commit: ${{ needs.resolve.outputs.commit }}
source_artifact: ${{ needs.resolve.outputs.source_artifact }}
matrix: ${{ needs.resolve.outputs.rocm_matrix }}
assemble:
name: Assemble metadata + publish
needs: [resolve, build-cpu, build-macos, build-cuda, build-windows-cuda, build-vulkan, build-rocm]
# Tiered gate: run whenever the P0 children (CPU + macOS) succeeded, even if
# a P1 GPU child failed or was skipped. A P0 failure short-circuits here and
# nothing is published.
if: >-
always()
&& needs.resolve.result == 'success'
&& needs.build-cpu.result == 'success'
&& needs.build-macos.result == 'success'
&& (needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-22.04
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout build tooling (this repo)
uses: actions/checkout@v6
with:
path: tooling
- name: Download built bundles
id: download
uses: actions/download-artifact@v6
continue-on-error: true
with:
path: dist
pattern: whisper-*
merge-multiple: true
- name: Download built bundles (retry)
if: steps.download.outcome == 'failure'
uses: actions/download-artifact@v6
with:
path: dist
pattern: whisper-*
merge-multiple: true
- name: Fetch stamped source archive
run: |
set -eux
TAG='${{ needs.resolve.outputs.tag }}'
# The resolve job's source artifact lands whisper.cpp-source-<tag>.tar.gz
# in dist/ via the whisper-* download; also stamp the commit-named copy.
SHA='${{ needs.resolve.outputs.commit }}'
cp "dist/whisper.cpp-source-${TAG}.tar.gz" "dist/whisper.cpp-source-commit-${SHA}.tar.gz"
# Branding gate: every bundle must carry the Unsloth fingerprint that
# package_bundle.py writes into its BUILD_INFO.txt (whisper-server has no
# compiled build-info string to bake it into, unlike llama.cpp). MARK must
# stay byte-identical to that string. grep -ar scans recursively.
- name: Verify Unsloth fingerprint in every bundle
run: |
set -euo pipefail
MARK='Compiled by the Unsloth team'
shopt -s nullglob
tmp="$(mktemp -d)"; fail=0; checked=0
for arc in dist/whisper-*.tar.gz dist/whisper-*.zip; do
case "$arc" in *whisper.cpp-source-*) continue ;; esac
d="$tmp/x"; rm -rf "$d"; mkdir -p "$d"
case "$arc" in
*.zip) unzip -qo "$arc" -d "$d" ;;
*.tar.gz) tar -xzf "$arc" -C "$d" ;;
esac
if grep -arq "$MARK" "$d"; then checked=$((checked+1));
else echo "ERROR: $(basename "$arc"): no file carries the Unsloth fingerprint" >&2; fail=1; fi
done
rm -rf "$tmp"
[ "$checked" -gt 0 ] || { echo "ERROR: no bundles found to verify" >&2; exit 1; }
[ "$fail" = 0 ] || { echo "ERROR: refusing to publish unbranded binaries" >&2; exit 1; }
echo "fingerprint verified in $checked bundles"
- name: Verify P0 coverage before publish
if: ${{ (inputs.publish || github.event_name == 'schedule') && needs.resolve.outputs.exists != 'true' }}
run: |
set -eu
TAG='${{ needs.resolve.outputs.tag }}'
fail=0
for f in \
"whisper-${TAG}-linux-x64-cpu.tar.gz" \
"whisper-${TAG}-linux-arm64-cpu.tar.gz" \
"whisper-${TAG}-windows-x64-cpu.zip" \
"whisper-${TAG}-macos-x64-cpu.tar.gz" \
"whisper-${TAG}-macos-arm64-metal.tar.gz"; do
[ -s "dist/$f" ] || { echo "ERROR: missing required P0 asset $f" >&2; fail=1; }
done
[ "$fail" = 0 ] || { echo "ERROR: refusing to publish an incomplete P0 set" >&2; exit 1; }
echo "P0 coverage complete"
- name: Generate manifest + sha256 index
run: |
set -eux
python3 tooling/scripts/package_bundle.py \
--emit-manifest --emit-sha256 \
--dist dist --out dist \
--tag '${{ needs.resolve.outputs.tag }}' \
--upstream-tag '${{ needs.resolve.outputs.upstream_tag }}' \
--commit '${{ needs.resolve.outputs.commit }}' \
--source-repo "$GITHUB_REPOSITORY"
# Drop the per-asset .entry.json sidecars: the manifest supersedes them.
rm -f dist/*.entry.json
ls -la dist
- name: Upload full release set (artifacts)
uses: actions/upload-artifact@v6
with:
name: unsloth-whisper-prebuilt-${{ needs.resolve.outputs.tag }}
path: dist/*
if-no-files-found: error
retention-days: 7
- name: Publish GitHub release (draft first, then atomic)
if: ${{ (inputs.publish || github.event_name == 'schedule') && needs.resolve.outputs.exists != 'true' }}
run: |
set -eux
TAG='${{ needs.resolve.outputs.tag }}'
UT='${{ needs.resolve.outputs.upstream_tag }}'
REPO="$GITHUB_REPOSITORY"
NOTES="Automated Unsloth whisper.cpp (whisper-server) prebuild for upstream [${UT}](https://github.com/ggml-org/whisper.cpp/releases/tag/${UT})."
# Atomic publish: upload as a draft (hidden from the anon API the
# installer reads), then flip draft=false only once every asset landed.
if [ "$(gh release view "$TAG" --repo "$REPO" --json isDraft --jq .isDraft 2>/dev/null || true)" = "true" ]; then
gh release delete "$TAG" --repo "$REPO" --yes
fi
# Exclude the per-asset entry sidecars (already removed) and upload the
# bundles, source tarballs, manifest and sha256 index.
gh release create "$TAG" --repo "$REPO" --draft \
--title "whisper.cpp prebuilt $TAG" \
--notes "$NOTES" \
dist/*
gh release edit "$TAG" --repo "$REPO" --draft=false