|
# SPDX-License-Identifier: MIT |
|
# Copyright 2026-present the Unsloth AI Inc. team. |
|
|
|
name: Unsloth SD prebuilt (CPU/Apple/CUDA) |
|
|
|
# Build and publish OUR OWN stable-diffusion.cpp (sd-cli + sd-server) prebuilts for |
|
# the platforms where the native engine is the FASTER choice: CPU (Linux/WSL/Windows), |
|
# Apple (Metal), and now Linux CUDA. The last one is a correction: this pipeline was |
|
# built on the premise that a GPU host runs diffusers/torch instead, and MiniMax-H3 |
|
# breaks that premise by needing more VRAM than a consumer card has, which sends those |
|
# hosts to the GGUF engine after all. |
|
# |
|
# Shape mirrors unslothai/llama.cpp's prebuilt pipeline, simplified for a 5-way |
|
# CPU/Apple matrix plus one best-effort CUDA leg: |
|
# resolve -- take THIS repository at the requested ref, merge the pinned PR set |
|
# in scripts/unsloth/pr-set.json, stamp a source tarball, name it |
|
# after the upstream release the tree descends from (supply-chain |
|
# aged), decide if our release already exists. |
|
# build-unix -- macOS arm64 (Metal) + x64, Linux x64 + arm64 (matrix). |
|
# build-windows-- Windows x64 (MSVC + Ninja). |
|
# build-linux-cuda -- Linux x64, CUDA 12.8, best effort (continue-on-error). |
|
# assemble -- fingerprint gate, sha256 + manifest, coverage gate, atomic |
|
# draft->publish. If any of the five CPU/Apple legs fails, assemble |
|
# is skipped and nothing is published (the Studio needs the full |
|
# asset set). The CUDA leg is exempt: it ships when it built and is |
|
# absent when it did not, so it can never hold back the fallbacks. |
|
# |
|
# Asset names match the Studio installer's resolve_release_asset. |
|
# |
|
# We build THIS repository's tree, not a tarball fetched from anywhere else. Our own fixes are |
|
# commits here, so there is nothing to re-apply at build time and nothing to keep in step with a |
|
# foreign tag. The published tag still names the upstream release the tree descends from, so a box |
|
# can say what it is built on without the build depending on that repository. |
|
# |
|
# On top of that tree, scripts/unsloth/pr-set.json can pin pull requests in THIS repository to |
|
# exact reviewed commits, merged in listed order at build time. That is the same mechanism as |
|
# unslothai/llama.cpp, with one deliberate difference: there the base is a pristine upstream |
|
# release and every Unsloth change stays pinned, whereas here the base is our own tree, so a |
|
# merged fix is simply in it and its pin is deleted. To carry a fix that exists as an upstream |
|
# pull request, vendor it here as a PR first and pin that; the build never fetches from another |
|
# repository. |
|
|
|
on: |
|
schedule: |
|
- cron: '17 20 * * *' # ~1pm PT-ish; daily |
|
workflow_dispatch: |
|
inputs: |
|
ref: |
|
description: 'Ref in THIS repository to build (branch, tag or sha)' |
|
default: 'master' |
|
required: true |
|
type: string |
|
min_age_hours: |
|
description: 'Refuse a tree whose base upstream release is younger than this many hours (blank = 6)' |
|
default: '' |
|
required: false |
|
type: string |
|
publish: |
|
description: 'Publish to GitHub Releases (false = build artifacts only)' |
|
default: false |
|
required: false |
|
type: boolean |
|
keep_artifacts: |
|
description: 'Keep this run''s artifacts even if it publishes nothing (artifact-only test runs)' |
|
default: false |
|
required: false |
|
type: boolean |
|
|
|
permissions: |
|
contents: read |
|
|
|
concurrency: |
|
group: sd-prebuilt-${{ github.event.inputs.ref || 'scheduled' }} |
|
cancel-in-progress: false |
|
|
|
env: |
|
UNSLOTH_SD_MIN_RELEASE_AGE_HOURS: "6" |
|
|
|
jobs: |
|
resolve: |
|
name: Resolve tag + stamp source |
|
runs-on: ubuntu-22.04 |
|
permissions: |
|
contents: read |
|
outputs: |
|
tag: ${{ steps.r.outputs.tag }} |
|
upstream_tag: ${{ steps.r.outputs.upstream_tag }} |
|
ahead: ${{ steps.r.outputs.ahead }} |
|
pins: ${{ steps.r.outputs.pins }} |
|
commit: ${{ steps.r.outputs.commit }} |
|
exists: ${{ steps.r.outputs.exists }} |
|
source_artifact: ${{ steps.r.outputs.source_artifact }} |
|
env: |
|
GH_TOKEN: ${{ github.token }} |
|
steps: |
|
- name: Checkout this repository with full history, which the tag name is derived from |
|
uses: actions/checkout@v4 |
|
with: |
|
ref: ${{ github.event.inputs.ref || 'master' }} |
|
fetch-depth: 0 |
|
fetch-tags: true |
|
|
|
- id: r |
|
run: | |
|
set -euo pipefail |
|
AGE_H='${{ github.event.inputs.min_age_hours }}' |
|
[ -n "$AGE_H" ] || AGE_H="${UNSLOTH_SD_MIN_RELEASE_AGE_HOURS:-6}" |
|
|
|
# The tree we build is this checkout. The tag still has to say which upstream release |
|
# it descends from, so read that from the history rather than from a foreign API. |
|
# |
|
# HIGHEST reachable release, not nearest. `git describe` answers "nearest", and on a |
|
# merge-shaped history that is the wrong answer: this tree reaches master-813 through a |
|
# merge 73 commits back and master-811 on its own line 13 commits back, so describe |
|
# names it after 811 and understates what it actually contains. The strict regex also |
|
# keeps out our own published tags, which live in this repository and carry a -u suffix. |
|
UPSTREAM_TAG="$(git tag -l --merged HEAD 'master-*' \ |
|
| grep -E '^master-[0-9]+-[0-9a-f]+$' | sort -t- -k2,2n | tail -1)" |
|
[ -n "$UPSTREAM_TAG" ] || { |
|
echo "no upstream release tag is reachable from this ref; cannot name the build" >&2 |
|
exit 1 |
|
} |
|
|
|
# Supply-chain aging, kept but re-pointed: it now guards the upstream release the tree |
|
# descends from, not the moment we merged it. A tree whose base is hours old is the |
|
# thing worth waiting on; our own commits on top are reviewed here. |
|
BASE_TS="$(git log -1 --format=%ct "refs/tags/${UPSTREAM_TAG}")" |
|
CUTOFF="$(date -u -d "-${AGE_H} hours" +%s)" |
|
[ "$BASE_TS" -le "$CUTOFF" ] || { |
|
echo "base release ${UPSTREAM_TAG} is younger than ${AGE_H}h; refusing" >&2 |
|
exit 1 |
|
} |
|
|
|
# Resolve the PR mix set (scripts/unsloth/pr-set.json). Same mechanism as |
|
# unslothai/llama.cpp: an exact, reviewed commit per entry, so an author pushing more |
|
# commits cannot change what the nightly ships. Only PRs in THIS repository may be |
|
# pinned; to carry an upstream fix, vendor it here as a PR first and pin that. |
|
# |
|
# The gate lives here rather than only in a lint, because a red lint does not stop the |
|
# schedule. |
|
jq -e '.prs | type == "array" and all(.[]; |
|
type == "string" |
|
or (type == "object" and (.url | type == "string") |
|
and ((if .required == null then true else .required end) | type == "boolean")))' \ |
|
scripts/unsloth/pr-set.json >/dev/null \ |
|
|| { echo "scripts/unsloth/pr-set.json: .prs must be an array of PR url strings, or {url, required} objects" >&2; exit 1; } |
|
PRS='[]' |
|
URL_RE='^https://github\.com/unslothai/stable-diffusion\.cpp/pull/([0-9]+)/commits/([0-9a-f]{40})/?$' |
|
while read -r url REQUIRED; do |
|
[[ "$url" =~ $URL_RE ]] || { echo "refusing malformed PR url '$url' (expected https://github.com/unslothai/stable-diffusion.cpp/pull/<n>/commits/<40-hex-sha>)" >&2; exit 1; } |
|
NUM="${BASH_REMATCH[1]}"; SHA="${BASH_REMATCH[2]}" |
|
PR_JSON="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${NUM}")" \ |
|
|| { echo "refusing #${NUM}: could not fetch PR metadata (nonexistent number in '$url', or a transient API failure); fix the pin or retry" >&2; exit 1; } |
|
STATE="$(jq -r '.state' <<<"$PR_JSON")" |
|
HEAD_PR="$(jq -r '.head.sha' <<<"$PR_JSON")" |
|
N_COMMITS="$(jq -r '.commits' <<<"$PR_JSON")" |
|
TITLE="$(jq -r '.title' <<<"$PR_JSON")" |
|
if [ "$STATE" != "open" ]; then |
|
# Dropping a pin changes SETHASH and the tag, so the release would ship without that |
|
# change under a new name. Refuse unless the entry is explicitly optional. |
|
if [ "$REQUIRED" != "false" ]; then |
|
echo "refusing #${NUM}: pin is ${STATE} and required. If it merged, delete the pin (its commits are already in this tree). Otherwise mark it \"required\": false or repin it." >&2 |
|
exit 1 |
|
fi |
|
echo "::warning::skipping optional pin #${NUM} (${STATE}): $url" |
|
continue |
|
fi |
|
# A pin pasted from the wrong PR would build arbitrary code while the manifest blames |
|
# #<num>. The commits listing is capped at 250; past that, skip rather than false-fail. |
|
if [ "$N_COMMITS" -gt 250 ]; then |
|
echo "note: #${NUM} has ${N_COMMITS} commits (over the API listing cap); skipping pin membership check" |
|
elif ! gh api "repos/${GITHUB_REPOSITORY}/pulls/${NUM}/commits" --paginate --jq '.[].sha' | grep -qx "$SHA"; then |
|
echo "refusing #${NUM}: pinned commit ${SHA} is not a commit of that PR (wrong paste, or force-pushed away)" >&2 |
|
exit 1 |
|
fi |
|
[ "$SHA" = "$HEAD_PR" ] || echo "note: #${NUM} is pinned to ${SHA} but its head has moved to ${HEAD_PR}" |
|
echo "including #${NUM} @ ${SHA}" |
|
PRS="$(jq -c --arg n "$NUM" --arg s "$SHA" --arg u "$url" --arg t "$TITLE" '. + [{number: ($n|tonumber), sha: $s, url: $u, title: $t}]' <<<"$PRS")" |
|
done < <(jq -r '.prs[] | if type == "string" then {url: ., required: true} else . end |
|
| "\(.url)\t\(if .required == null then true else .required end)"' scripts/unsloth/pr-set.json | tr '\t' ' ') |
|
|
|
# Merge the pinned commits onto this tree, in listed order (merge order matters for |
|
# conflicts). diff3 so additive_merge.py can see the merge base and refuse to guess on |
|
# anything that is not a provable add/add. |
|
for row in $(jq -r '.[] | "\(.number):\(.sha)"' <<<"$PRS"); do |
|
NUM="${row%%:*}"; SHA="${row##*:}" |
|
git fetch -q --no-tags origin "$SHA" \ |
|
|| git fetch -q --no-tags origin "refs/pull/${NUM}/head" \ |
|
|| { echo "could not fetch commit ${SHA} for #${NUM}" >&2; exit 1; } |
|
git -c user.name='Unsloth CI' -c user.email='ci@unsloth.ai' \ |
|
-c merge.conflictStyle=diff3 merge --no-edit "$SHA" && continue |
|
python3 scripts/unsloth/additive_merge.py \ |
|
|| { echo "merging #${NUM} conflicts in a way that needs a human; fix the pin" >&2; exit 1; } |
|
git -c user.name='Unsloth CI' -c user.email='ci@unsloth.ai' commit --no-edit -q |
|
echo "merged #${NUM} with additive conflict resolution" |
|
done |
|
|
|
# Always -u<id>: the tree is ours, never a stock upstream one, and the suffix is what |
|
# tells Studio's installer to go straight to this repository's releases instead of |
|
# trying an upstream download that is guaranteed to 404. With no pins the id is the head |
|
# sha; with pins it also hashes the pinned number:sha pairs, so a repin or a reorder |
|
# yields a new tag and a rebuild while an unchanged set still matches and skips. |
|
HEAD_SHA7="$(git rev-parse --short=7 HEAD)" |
|
if [ "$(jq length <<<"$PRS")" = 0 ]; then |
|
TAG_ID="$HEAD_SHA7" |
|
else |
|
TAG_ID="$(jq -r 'map("\(.number):\(.sha)") | join("\n")' <<<"$PRS" \ |
|
| { cat; echo "$HEAD_SHA7"; } | sha256sum | cut -c1-7)" |
|
fi |
|
TAG="${UPSTREAM_TAG}-u${TAG_ID}" |
|
AHEAD="$(git rev-list --count "refs/tags/${UPSTREAM_TAG}..HEAD")" |
|
PIN_LIST="$(jq -r 'map("#\(.number)") | join(",")' <<<"$PRS")" |
|
echo "building this repository at ${HEAD_SHA7} (${AHEAD} commits past ${UPSTREAM_TAG}), pins [${PIN_LIST}] -> ${TAG}" |
|
|
|
# Does OUR published release already exist? (drafts don't count.) Checked against the |
|
# final tag, so a new commit or a repin republishes instead of matching. |
|
EXISTS=false |
|
if [ "$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft 2>/dev/null || true)" = "false" ]; then |
|
EXISTS=true |
|
fi |
|
# ggml is a REQUIRED submodule; the server frontend + libwebp/libwebm are not |
|
# (we build with SD_SERVER_BUILD_FRONTEND / SD_WEBP / SD_WEBM off), so fetch only |
|
# ggml to keep the source tarball small and the build self-contained. |
|
git submodule update --init --recursive --depth 1 ggml |
|
COMMIT="$(git rev-parse HEAD)" |
|
SRC_ARTIFACT="sd-source-${TAG}" |
|
if [ "$EXISTS" != "true" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then |
|
tar --exclude-vcs --exclude=.github -czf "${RUNNER_TEMP}/sd-source-${TAG}.tar.gz" . |
|
echo "stamped source for ${TAG} (${COMMIT})" |
|
else |
|
echo "release ${TAG} already published; skipping source prep" |
|
fi |
|
|
|
{ |
|
echo "tag=$TAG" |
|
echo "upstream_tag=$UPSTREAM_TAG" |
|
echo "ahead=$AHEAD" |
|
echo "pins=$PIN_LIST" |
|
echo "commit=$COMMIT" |
|
echo "exists=$EXISTS" |
|
echo "source_artifact=$SRC_ARTIFACT" |
|
} >> "$GITHUB_OUTPUT" |
|
|
|
- name: Upload source artifact |
|
if: ${{ steps.r.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }} |
|
uses: actions/upload-artifact@v4 |
|
with: |
|
name: ${{ steps.r.outputs.source_artifact }} |
|
path: ${{ runner.temp }}/sd-source-${{ steps.r.outputs.tag }}.tar.gz |
|
if-no-files-found: error |
|
retention-days: 7 |
|
|
|
build-unix: |
|
name: ${{ matrix.label }} |
|
needs: resolve |
|
if: ${{ needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }} |
|
strategy: |
|
fail-fast: false |
|
matrix: |
|
include: |
|
- label: Darwin-macOS-arm64 |
|
runner: macos-14 |
|
arch: arm64 |
|
defines: "-DSD_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON -DCMAKE_OSX_DEPLOYMENT_TARGET=14.0" |
|
gate: "true" |
|
- label: Darwin-macOS-x86_64 |
|
runner: macos-15-intel |
|
arch: x86_64 |
|
defines: "-DSD_METAL=OFF -DCMAKE_OSX_DEPLOYMENT_TARGET=13.3" |
|
gate: "true" |
|
- label: Linux-Ubuntu-22.04-x86_64 |
|
runner: ubuntu-22.04 |
|
arch: x86_64 |
|
defines: "" |
|
gate: "false" |
|
- label: Linux-Ubuntu-24.04-aarch64 |
|
runner: ubuntu-24.04-arm |
|
arch: aarch64 |
|
defines: "" |
|
gate: "false" |
|
runs-on: ${{ matrix.runner }} |
|
steps: |
|
- name: Checkout mirror (tooling) |
|
uses: actions/checkout@v4 |
|
with: |
|
path: tooling |
|
fetch-depth: 1 |
|
|
|
- name: Download source @ ${{ needs.resolve.outputs.tag }} |
|
uses: actions/download-artifact@v4 |
|
with: |
|
name: ${{ needs.resolve.outputs.source_artifact }} |
|
path: srcpkg |
|
- name: Extract source |
|
run: | |
|
set -eux |
|
mkdir -p src |
|
tar -xzf "srcpkg/sd-source-${{ needs.resolve.outputs.tag }}.tar.gz" -C src |
|
|
|
- name: Build sd-cli + sd-server |
|
working-directory: src |
|
run: | |
|
set -euo pipefail |
|
cmake -B build \ |
|
-DCMAKE_BUILD_TYPE=Release \ |
|
-DSD_BUILD_EXAMPLES=ON \ |
|
-DSD_SERVER_BUILD_FRONTEND=OFF \ |
|
-DSD_WEBP=OFF -DSD_WEBM=OFF \ |
|
-DGGML_NATIVE=OFF \ |
|
${{ matrix.defines }} |
|
if [ "$(uname -s)" = "Darwin" ]; then J="$(sysctl -n hw.logicalcpu)"; else J="$(nproc)"; fi |
|
cmake --build build --config Release -j "$J" --target sd-cli sd-server |
|
|
|
- name: macOS load gate |
|
if: ${{ matrix.gate == 'true' }} |
|
run: | |
|
set -eux |
|
# deploy target is the trailing number in defines |
|
DT="$(echo '${{ matrix.defines }}' | sed -E 's/.*CMAKE_OSX_DEPLOYMENT_TARGET=([0-9.]+).*/\1/')" |
|
bash tooling/scripts/unsloth/assert_macho_minos.sh src/build/bin "${{ matrix.arch }}" "$DT" |
|
|
|
- name: Package bundle |
|
env: |
|
BIN_DIR: ${{ github.workspace }}/src/build/bin |
|
OUT_DIR: ${{ github.workspace }}/dist |
|
TAG: ${{ needs.resolve.outputs.tag }} |
|
LABEL: ${{ matrix.label }} |
|
COMMIT: ${{ needs.resolve.outputs.commit }} |
|
SOURCE_REPO: ${{ github.repository }} |
|
LICENSE_FILE: ${{ github.workspace }}/src/LICENSE |
|
run: python3 tooling/scripts/unsloth/package_bundle.py |
|
|
|
- name: Upload bundle |
|
uses: actions/upload-artifact@v4 |
|
with: |
|
name: sd-${{ needs.resolve.outputs.tag }}-bin-${{ matrix.label }} |
|
path: dist/sd-${{ needs.resolve.outputs.tag }}-bin-${{ matrix.label }}.zip |
|
if-no-files-found: error |
|
retention-days: 7 |
|
|
|
# Linux CUDA. The rest of this pipeline is CPU and Apple on the premise that GPU hosts use |
|
# diffusers/torch, and that premise fails for MiniMax-H3: its Diffusers path needs about |
|
# 68.5 GB of VRAM, so every consumer card falls back to the GGUF engine, which on Linux had |
|
# no accelerated build at all. Measured on one box: 65 s/step at 320x192 on 96 CPU threads, |
|
# against 21.5 s/step at 960x544 from a CUDA build. That is the difference between a render |
|
# taking four hours and taking eleven minutes. |
|
# |
|
# continue-on-error, and deliberately so. The CPU and Apple assets are what Studio falls back |
|
# to, and a CUDA toolchain failure must never stop them publishing. assemble collects bundles |
|
# by the sd-*-bin-* pattern, so this asset appears when it built and is simply absent when it |
|
# did not; the coverage gate lists only the five CPU/Apple assets and must keep doing so. |
|
build-linux-cuda: |
|
name: Linux-Ubuntu-22.04-x86_64-cuda12 |
|
needs: resolve |
|
continue-on-error: true |
|
if: ${{ needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }} |
|
runs-on: ubuntu-22.04 |
|
# One source of truth for both: the ccache key has to track whatever changes |
|
# the objects, and these two do. |
|
env: |
|
CUDA_VERSION: "12.8.1" |
|
CUDA_ARCHS: "75;80;86;89;90;100;120" |
|
steps: |
|
- name: Checkout mirror (tooling) |
|
uses: actions/checkout@v4 |
|
with: |
|
path: tooling |
|
fetch-depth: 1 |
|
|
|
- name: Free disk for the toolkit |
|
run: | |
|
set -eux |
|
# The hosted image ships ~25 GB free; the toolkit plus a seven-architecture ggml-cuda |
|
# build does not fit beside the preinstalled Android and .NET trees. |
|
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/share/boost |
|
df -h / |
|
|
|
- name: Download source @ ${{ needs.resolve.outputs.tag }} |
|
uses: actions/download-artifact@v4 |
|
with: |
|
name: ${{ needs.resolve.outputs.source_artifact }} |
|
path: srcpkg |
|
- name: Extract source |
|
run: | |
|
set -eux |
|
mkdir -p src |
|
tar -xzf "srcpkg/sd-source-${{ needs.resolve.outputs.tag }}.tar.gz" -C src |
|
|
|
- name: Install CUDA toolkit |
|
id: cuda-toolkit |
|
uses: Jimver/cuda-toolkit@v0.2.22 |
|
with: |
|
cuda: ${{ env.CUDA_VERSION }} |
|
method: "network" |
|
# sub-packages are installed as cuda-<name>-12-8. cuBLAS is not under that |
|
# prefix (it ships as libcublas / libcublas-dev), so it has to go in the |
|
# other list or apt cannot find it. cudart-dev is what carries the headers; |
|
# cudart alone is the runtime and does not compile anything. |
|
sub-packages: '["nvcc", "cudart", "cudart-dev", "thrust"]' |
|
non-cuda-sub-packages: '["libcublas", "libcublas-dev"]' |
|
|
|
# This leg rebuilt every object on every run: 3903 s of the 4121 s job on |
|
# 2026-08-09, and 4942 s on the run before it, with no speedup between the |
|
# two. The repo held no cache entry for it at all, only build.yml's ROCm |
|
# ones. |
|
# |
|
# The key carries the CUDA version and the architecture list because both |
|
# decide the objects. ccache hashes compiler identity into every entry, so |
|
# a cache written by another toolkit can never hit -- a version-less key |
|
# is what held the llama.cpp ROCm legs at a 0% hit rate and made them that |
|
# pipeline's critical path (llama.cpp#88). restore-keys lets a new tag |
|
# start from the previous generation instead of from nothing. |
|
- name: ccache key |
|
id: cckey |
|
run: echo "archs=$(echo "$CUDA_ARCHS" | tr ';' '-')" >> "$GITHUB_OUTPUT" |
|
|
|
- name: ccache |
|
uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23 |
|
with: |
|
key: sd-cuda-${{ env.CUDA_VERSION }}-${{ steps.cckey.outputs.archs }}-${{ needs.resolve.outputs.tag }} |
|
restore-keys: | |
|
sd-cuda-${{ env.CUDA_VERSION }}-${{ steps.cckey.outputs.archs }} |
|
append-timestamp: false |
|
variant: ccache |
|
max-size: 2G |
|
# Saved by the explicit step below instead, so a failed build still |
|
# keeps what it compiled. |
|
save: false |
|
|
|
- name: Build sd-cli + sd-server (CUDA) |
|
working-directory: src |
|
run: | |
|
set -euo pipefail |
|
# Turing through Blackwell. 12.8 is the first toolkit that can emit sm_100 (B200) and |
|
# sm_120 (RTX 50), and anything older than Turing is not a realistic host for a 20 GB |
|
# video denoiser. build.yml's Windows leg uses the same list plus 61 and 70. |
|
# |
|
# The CUDA launcher matters more than the other two here: Jimver installs nvcc outside |
|
# the default search, and nvcc is nearly the whole build, so caching only C/CXX would |
|
# leave the expensive half uncached. |
|
cmake -B build \ |
|
-DCMAKE_BUILD_TYPE=Release \ |
|
-DSD_BUILD_EXAMPLES=ON \ |
|
-DSD_SERVER_BUILD_FRONTEND=OFF \ |
|
-DSD_WEBP=OFF -DSD_WEBM=OFF \ |
|
-DGGML_NATIVE=OFF \ |
|
-DSD_CUDA=ON \ |
|
-DCMAKE_CUDA_ARCHITECTURES="$CUDA_ARCHS" \ |
|
-DCMAKE_C_COMPILER_LAUNCHER=ccache \ |
|
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ |
|
-DCMAKE_CUDA_COMPILER_LAUNCHER=ccache |
|
cmake --build build --config Release -j "$(nproc)" --target sd-cli sd-server |
|
# Diagnostic only, under set -e: never fail a good build over stats. |
|
ccache --show-stats || true |
|
|
|
- name: Bundle the CUDA runtime beside the binaries |
|
run: | |
|
set -euo pipefail |
|
# A Studio host has an NVIDIA driver but not necessarily a CUDA runtime, and Studio's |
|
# torch install keeps its own copies somewhere we must not depend on. Ship ours and |
|
# point the binaries at their own directory, or sd-cli dies on a missing libcublas. |
|
BIN="${GITHUB_WORKSPACE}/src/build/bin" |
|
CUDA_ROOT="${{ steps.cuda-toolkit.outputs.CUDA_PATH }}" |
|
sudo apt-get update -qq && sudo apt-get install -y -qq patchelf |
|
# cudart lands under the toolkit root; the libcublas debs land in the system |
|
# multiarch dir. Search both rather than guess which is which. |
|
for so in libcudart.so.12 libcublas.so.12 libcublasLt.so.12; do |
|
src="$(find "$CUDA_ROOT" /usr/lib/x86_64-linux-gnu -name "${so}*" -type f 2>/dev/null | sort | tail -1)" |
|
[ -n "$src" ] || { echo "ERROR: $so not found under $CUDA_ROOT or /usr/lib/x86_64-linux-gnu" >&2; exit 1; } |
|
cp -L "$src" "$BIN/$so" |
|
done |
|
for exe in sd-cli sd-server; do |
|
patchelf --set-rpath '$ORIGIN' "$BIN/$exe" |
|
done |
|
ldd "$BIN/sd-cli" | sed -n '1,40p' |
|
|
|
- name: Package bundle |
|
env: |
|
BIN_DIR: ${{ github.workspace }}/src/build/bin |
|
OUT_DIR: ${{ github.workspace }}/dist |
|
TAG: ${{ needs.resolve.outputs.tag }} |
|
LABEL: Linux-Ubuntu-22.04-x86_64-cuda12 |
|
COMMIT: ${{ needs.resolve.outputs.commit }} |
|
SOURCE_REPO: ${{ github.repository }} |
|
LICENSE_FILE: ${{ github.workspace }}/src/LICENSE |
|
run: python3 tooling/scripts/unsloth/package_bundle.py |
|
|
|
- name: Upload bundle |
|
uses: actions/upload-artifact@v4 |
|
with: |
|
name: sd-${{ needs.resolve.outputs.tag }}-bin-Linux-Ubuntu-22.04-x86_64-cuda12 |
|
path: dist/sd-${{ needs.resolve.outputs.tag }}-bin-Linux-Ubuntu-22.04-x86_64-cuda12.zip |
|
if-no-files-found: error |
|
retention-days: 7 |
|
|
|
- name: Evict stale ccache files |
|
# !cancelled(), unlike the save below: a cancelled job gets one short |
|
# teardown window that is not replenished, and the save is what needs |
|
# it. |
|
if: ${{ !cancelled() }} |
|
continue-on-error: true |
|
run: ccache --evict-older-than 14d |
|
|
|
- name: Save ccache |
|
# Save even when the build failed. The objects compiled before the |
|
# failure still count, and this leg is continue-on-error, so a failure |
|
# here is routine. always(), not !cancelled(): a job killed by the cap |
|
# takes the cancellation path, and that is the most expensive case to |
|
# lose (llama.cpp#81). |
|
if: ${{ always() }} |
|
uses: actions/cache/save@v6 |
|
with: |
|
path: ${{ github.workspace }}/.ccache |
|
# Trailing dash keeps this distinct from the key the ccache action |
|
# restored from, so the save is never a no-op against itself. |
|
key: ccache-sd-cuda-${{ env.CUDA_VERSION }}-${{ steps.cckey.outputs.archs }}-${{ needs.resolve.outputs.tag }}- |
|
|
|
build-windows: |
|
name: win-cpu-x64 |
|
needs: resolve |
|
if: ${{ needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }} |
|
runs-on: windows-2022 |
|
steps: |
|
- name: Checkout mirror (tooling) |
|
uses: actions/checkout@v4 |
|
with: |
|
path: tooling |
|
fetch-depth: 1 |
|
|
|
- name: Download source @ ${{ needs.resolve.outputs.tag }} |
|
uses: actions/download-artifact@v4 |
|
with: |
|
name: ${{ needs.resolve.outputs.source_artifact }} |
|
path: srcpkg |
|
|
|
- name: Extract source |
|
shell: bash |
|
run: | |
|
set -eux |
|
mkdir -p src |
|
tar -xzf "srcpkg/sd-source-${{ needs.resolve.outputs.tag }}.tar.gz" -C src |
|
|
|
- uses: actions/setup-python@v5 |
|
with: |
|
python-version: "3.11" |
|
- name: Install Ninja |
|
run: choco install ninja --no-progress |
|
- name: Setup MSVC |
|
uses: ilammy/msvc-dev-cmd@v1 |
|
|
|
- name: Build sd-cli + sd-server |
|
shell: pwsh |
|
working-directory: src |
|
run: | |
|
cmake -S . -B build -G Ninja ` |
|
-DCMAKE_BUILD_TYPE=Release ` |
|
-DCMAKE_CXX_FLAGS='/bigobj' ` |
|
-DSD_BUILD_EXAMPLES=ON ` |
|
-DSD_SERVER_BUILD_FRONTEND=OFF ` |
|
-DSD_WEBP=OFF -DSD_WEBM=OFF ` |
|
-DGGML_NATIVE=OFF |
|
cmake --build build --config Release -j 3 --target sd-cli sd-server |
|
|
|
- name: Package bundle |
|
shell: pwsh |
|
env: |
|
BIN_DIR: ${{ github.workspace }}/src/build/bin |
|
OUT_DIR: ${{ github.workspace }}/dist |
|
TAG: ${{ needs.resolve.outputs.tag }} |
|
LABEL: win-cpu-x64 |
|
COMMIT: ${{ needs.resolve.outputs.commit }} |
|
SOURCE_REPO: ${{ github.repository }} |
|
LICENSE_FILE: ${{ github.workspace }}/src/LICENSE |
|
run: python tooling/scripts/unsloth/package_bundle.py |
|
|
|
- name: Upload bundle |
|
uses: actions/upload-artifact@v4 |
|
with: |
|
name: sd-${{ needs.resolve.outputs.tag }}-bin-win-cpu-x64 |
|
path: dist/sd-${{ needs.resolve.outputs.tag }}-bin-win-cpu-x64.zip |
|
if-no-files-found: error |
|
retention-days: 7 |
|
|
|
assemble: |
|
name: Assemble + publish |
|
needs: [resolve, build-unix, build-windows, build-linux-cuda] |
|
# Consumed by `reclaim` to tell "these bundles are now release assets" from |
|
# "nothing will ever read these". Set only after draft=false lands. |
|
outputs: |
|
published: ${{ steps.publish.outputs.published }} |
|
if: ${{ needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }} |
|
runs-on: ubuntu-22.04 |
|
permissions: |
|
contents: write |
|
env: |
|
GH_TOKEN: ${{ github.token }} |
|
steps: |
|
- name: Checkout mirror (tooling) |
|
uses: actions/checkout@v4 |
|
with: |
|
path: tooling |
|
fetch-depth: 1 |
|
|
|
- name: Download bundles |
|
uses: actions/download-artifact@v4 |
|
with: |
|
path: dist |
|
pattern: sd-*-bin-* |
|
merge-multiple: true |
|
|
|
- name: Fingerprint gate (every bundle carries the Unsloth mark) |
|
run: | |
|
set -euo pipefail |
|
MARK='Compiled by the Unsloth team' |
|
shopt -s nullglob |
|
tmp="$(mktemp -d)"; fail=0; checked=0 |
|
for z in dist/sd-*-bin-*.zip; do |
|
rm -rf "$tmp/x"; mkdir -p "$tmp/x" |
|
unzip -qo "$z" -d "$tmp/x" |
|
if grep -arq "$MARK" "$tmp/x"; then checked=$((checked+1)); else echo "ERROR: $(basename "$z") missing fingerprint" >&2; fail=1; fi |
|
done |
|
rm -rf "$tmp" |
|
[ "$checked" -gt 0 ] || { echo "ERROR: no bundles to verify" >&2; exit 1; } |
|
[ "$fail" = 0 ] || exit 1 |
|
echo "fingerprint verified in $checked bundles" |
|
|
|
- name: Generate manifest + sha256 index |
|
run: | |
|
set -eux |
|
python3 tooling/scripts/unsloth/assemble_metadata.py \ |
|
--tag '${{ needs.resolve.outputs.tag }}' \ |
|
--upstream-tag '${{ needs.resolve.outputs.upstream_tag }}' \ |
|
--patches '${{ needs.resolve.outputs.pins }}' \ |
|
--source-repo "$GITHUB_REPOSITORY" \ |
|
--commit '${{ needs.resolve.outputs.commit }}' \ |
|
--dist dist --out dist \ |
|
--publish-repo "$GITHUB_REPOSITORY" |
|
ls -la dist |
|
|
|
- name: Coverage gate (all 5 CPU/Apple assets present) |
|
run: | |
|
set -eu |
|
TAG='${{ needs.resolve.outputs.tag }}' |
|
fail=0 |
|
for f in \ |
|
"sd-${TAG}-bin-Darwin-macOS-arm64.zip" \ |
|
"sd-${TAG}-bin-Darwin-macOS-x86_64.zip" \ |
|
"sd-${TAG}-bin-Linux-Ubuntu-22.04-x86_64.zip" \ |
|
"sd-${TAG}-bin-Linux-Ubuntu-24.04-aarch64.zip" \ |
|
"sd-${TAG}-bin-win-cpu-x64.zip"; do |
|
[ -s "dist/$f" ] || { echo "ERROR: missing $f" >&2; fail=1; } |
|
done |
|
[ "$fail" = 0 ] || { echo "ERROR: refusing to publish a partial release" >&2; exit 1; } |
|
|
|
# Only on a run that did NOT succeed, matching llama.cpp. Uploading this |
|
# unconditionally duplicated every bundle in the run -- measured at 1.34 GiB |
|
# per run, byte-for-byte the same content as the six -bin- artifacts beside |
|
# it. On a green publish those bytes are already release assets, and |
|
# reclaim below cannot delete this copy because an aggregate matches no |
|
# single asset name, so it was the largest survivor of every cleanup. |
|
# |
|
# !success() rather than failure(): a cancelled run is exactly when a |
|
# human wants the rescue bundle. |
|
- name: Upload full set (artifact fallback) |
|
if: ${{ always() && !success() }} |
|
uses: actions/upload-artifact@v4 |
|
with: |
|
name: unsloth-sd-prebuilt-${{ needs.resolve.outputs.tag }} |
|
path: dist/* |
|
# warn, not error: an early failure can leave dist/ absent, and a |
|
# missing debug bundle must not turn a diagnosable failure into a |
|
# confusing second one. |
|
if-no-files-found: warn |
|
overwrite: true |
|
retention-days: 7 |
|
|
|
- name: Publish GitHub release |
|
id: publish |
|
if: ${{ (github.event_name == 'schedule' || inputs.publish) && needs.resolve.outputs.exists != 'true' }} |
|
run: | |
|
set -eux |
|
TAG='${{ needs.resolve.outputs.tag }}' |
|
UPSTREAM_TAG='${{ needs.resolve.outputs.upstream_tag }}' |
|
AHEAD='${{ needs.resolve.outputs.ahead }}' |
|
COMMIT='${{ needs.resolve.outputs.commit }}' |
|
REPO="$GITHUB_REPOSITORY" |
|
NOTES="Automated Unsloth stable-diffusion.cpp CPU + Apple prebuild (sd-cli + sd-server), built from [\`${COMMIT}\`](https://github.com/${REPO}/commit/${COMMIT}) in this repository. GPU hosts use diffusers/torch; this native engine targets CPU (Linux/WSL/Windows) and Apple (Metal)." |
|
PINS='${{ needs.resolve.outputs.pins }}' |
|
if [ "${AHEAD:-0}" -gt 0 ]; then |
|
NOTES="${NOTES}"$'\n\n'"Not a stock build: the tree is ${AHEAD} commits past the ${UPSTREAM_TAG} release it descends from, which is what the \`-u\` suffix on the tag marks. \`sd-prebuilt-manifest.json\` records the commit." |
|
fi |
|
if [ -n "$PINS" ]; then |
|
NOTES="${NOTES}"$'\n\n'"It also carries these pinned pull requests, merged at build time from \`scripts/unsloth/pr-set.json\`: ${PINS}." |
|
fi |
|
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 |
|
gh release create "$TAG" --repo "$REPO" --draft \ |
|
--title "stable-diffusion.cpp prebuilt $TAG" \ |
|
--notes "$NOTES" \ |
|
dist/* |
|
gh release edit "$TAG" --repo "$REPO" --draft=false |
|
echo "published=true" >> "$GITHUB_OUTPUT" |
|
|
|
|
|
# Ported from unslothai/llama.cpp's unsloth-prebuilt.yml `reclaim` job, which |
|
# this pipeline was copied from without it. Nothing here ever deleted its own |
|
# artifacts, so every run's bundles sat until GitHub's retention expired them: |
|
# measured 2026-08-11, 168 live artifacts / 13.49 GiB, spread over 27 runs |
|
# whose binaries were already published as release assets. |
|
reclaim: |
|
name: Reclaim artifact storage |
|
needs: [resolve, assemble] |
|
# always(), so a run that publishes NOTHING still cleans up after itself. |
|
# Gating on `published` is what leaks: a workflow_dispatch defaults to |
|
# publish:false, and a cancelled run never reaches publish either. |
|
if: ${{ always() }} |
|
runs-on: ubuntu-24.04 |
|
timeout-minutes: 20 |
|
permissions: |
|
actions: write # delete this run's artifacts |
|
contents: read # read the release asset list |
|
steps: |
|
- name: Delete artifacts already published as release assets |
|
if: ${{ needs.assemble.outputs.published == 'true' }} |
|
# Never fail a published release over cleanup. |
|
continue-on-error: true |
|
env: |
|
GH_TOKEN: ${{ github.token }} |
|
TAG: ${{ needs.resolve.outputs.tag }} |
|
run: | |
|
set -euo pipefail |
|
repo="$GITHUB_REPOSITORY" |
|
|
|
if [ -z "${TAG:-}" ]; then |
|
echo "no tag resolved; leaving artifacts untouched" |
|
exit 0 |
|
fi |
|
|
|
# Gate 1: the release must exist and be published, not a draft. |
|
draft="$(gh release view "$TAG" --repo "$repo" --json isDraft -q .isDraft 2>/dev/null || echo missing)" |
|
if [ "$draft" != "false" ]; then |
|
echo "release $TAG is '$draft', not a published release; leaving artifacts untouched" |
|
exit 0 |
|
fi |
|
assets="$RUNNER_TEMP/reclaim-assets.txt" |
|
arts="$RUNNER_TEMP/reclaim-arts.tsv" |
|
gh release view "$TAG" --repo "$repo" --json assets -q '.assets[].name' | sort > "$assets" |
|
echo "release $TAG has $(wc -l < "$assets") assets" |
|
|
|
# Gate 2: only THIS run's artifacts are even considered, so the step |
|
# cannot reach another run's -- including a concurrent build's. |
|
gh api "repos/$repo/actions/runs/$GITHUB_RUN_ID/artifacts" --paginate \ |
|
-q '.artifacts[] | select(.expired==false) | "\(.id)\t\(.size_in_bytes)\t\(.name)"' > "$arts" || true |
|
echo "this run has $(grep -c . "$arts" || true) live artifacts" |
|
|
|
freed=0; deleted=0; kept=0; failed=0 |
|
while IFS="$(printf '\t')" read -r id size name; do |
|
[ -z "${id:-}" ] && continue |
|
# Gate 3: delete only what is provably already on the release. |
|
# Build children upload `sd-<tag>-bin-<label>`; assemble publishes it |
|
# as `<name>.zip`. Anything that does not match is KEPT -- that is |
|
# what protects a partial publish, and it also keeps the |
|
# `unsloth-sd-prebuilt-<tag>` full-set fallback artifact. |
|
# -F: fixed string. Without it every `.` in the name is a regex |
|
# wildcard, and the tag carries dots (Ubuntu-22.04). |
|
if grep -qxF -- "${name}.zip" "$assets" || grep -qxF -- "${name}.tar.gz" "$assets"; then |
|
# < /dev/null so the command can never consume the loop's stdin |
|
# and silently truncate the sweep to one artifact. |
|
if err="$(gh api -X DELETE "repos/$repo/actions/artifacts/$id" --silent < /dev/null 2>&1)"; then |
|
freed=$(( freed + size )); deleted=$(( deleted + 1 )) |
|
else |
|
printf ' could not delete %s: %s\n' "$name" "$err" |
|
failed=$(( failed + 1 )) |
|
fi |
|
else |
|
printf ' KEEP %s (no matching release asset)\n' "$name" |
|
kept=$(( kept + 1 )) |
|
fi |
|
done < "$arts" |
|
|
|
echo "deleted $deleted artifacts, freed $(( freed / 1048576 )) MiB, kept $kept, failed $failed" |
|
if [ "$failed" -gt 0 ]; then |
|
echo "::warning::$failed artifact(s) could not be deleted; storage will be reclaimed by retention instead" |
|
fi |
|
{ |
|
echo "### Artifact storage reclaimed" |
|
echo "" |
|
echo "| metric | value |" |
|
echo "| --- | --- |" |
|
echo "| release | \`$TAG\` |" |
|
echo "| artifacts deleted | $deleted |" |
|
echo "| storage freed | $(( freed / 1048576 )) MiB |" |
|
echo "| kept (no release asset) | $kept |" |
|
echo "| delete failures | $failed |" |
|
} >> "$GITHUB_STEP_SUMMARY" |
|
|
|
- name: Delete artifacts of a run that published nothing |
|
# The other step's name-match against release assets is meaningless |
|
# here: either no release was written, or the tag belongs to a DIFFERENT |
|
# run's release. So the rule is simply that nothing will ever consume |
|
# these -- a publish:false dispatch is a test, and a cancelled or failed |
|
# run is not resumable past the missing legs -- and they are deleted. |
|
if: ${{ needs.assemble.outputs.published != 'true' && inputs.keep_artifacts != true }} |
|
continue-on-error: true |
|
env: |
|
GH_TOKEN: ${{ github.token }} |
|
run: | |
|
set -euo pipefail |
|
repo="$GITHUB_REPOSITORY" |
|
arts="$RUNNER_TEMP/reclaim-unpublished.tsv" |
|
|
|
# Same containment as the published path: only THIS run's artifacts |
|
# are listed, so the step cannot reach a concurrent build's. |
|
gh api "repos/$repo/actions/runs/$GITHUB_RUN_ID/artifacts" --paginate \ |
|
-q '.artifacts[] | select(.expired==false) | "\(.id)\t\(.size_in_bytes)\t\(.name)"' > "$arts" || true |
|
n="$(grep -c . "$arts" || true)" |
|
echo "run published nothing; deleting its $n live artifact(s)" |
|
|
|
freed=0; deleted=0; failed=0 |
|
while IFS="$(printf '\t')" read -r id size name; do |
|
[ -z "${id:-}" ] && continue |
|
if err="$(gh api -X DELETE "repos/$repo/actions/artifacts/$id" --silent < /dev/null 2>&1)"; then |
|
freed=$(( freed + size )); deleted=$(( deleted + 1 )) |
|
else |
|
printf ' could not delete %s: %s\n' "$name" "$err" |
|
failed=$(( failed + 1 )) |
|
fi |
|
done < "$arts" |
|
|
|
echo "deleted $deleted artifacts, freed $(( freed / 1048576 )) MiB, failed $failed" |
|
if [ "$failed" -gt 0 ]; then |
|
echo "::warning::$failed artifact(s) could not be deleted; storage will be reclaimed by retention instead" |
|
fi |
|
{ |
|
echo "### Artifact storage reclaimed (unpublished run)" |
|
echo "" |
|
echo "| metric | value |" |
|
echo "| --- | --- |" |
|
echo "| artifacts deleted | $deleted |" |
|
echo "| storage freed | $(( freed / 1048576 )) MiB |" |
|
echo "| delete failures | $failed |" |
|
} >> "$GITHUB_STEP_SUMMARY" |