Unsloth SD prebuilt (CPU/Apple/CUDA) #77
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| 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@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| path: tooling | |
| fetch-depth: 1 | |
| - name: Download source @ ${{ needs.resolve.outputs.tag }} | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| 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@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| 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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| 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@95b5a29a2823ef87666dcd45ec236fc4f82498d0 # 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| 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 | |
| # Gate on the environment, matching unslothai/unsloth's release-desktop.yml: | |
| # the job that can read the signing credentials is named explicitly. | |
| environment: release-signing | |
| steps: | |
| - name: Checkout mirror (tooling) | |
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| path: tooling | |
| fetch-depth: 1 | |
| - name: Download source @ ${{ needs.resolve.outputs.tag }} | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| 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@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 | |
| with: | |
| python-version: "3.11" | |
| - name: Install Ninja | |
| run: choco install ninja --no-progress | |
| - name: Setup MSVC | |
| uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 | |
| - 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 | |
| # Before packaging, so every PE the bundle carries is signed. Windows | |
| # Smart App Control evaluates each binary as it loads and refuses an | |
| # unknown unsigned one with status 0xc0e90002, which the user sees as a | |
| # "Bad Image" dialog naming the file. sd-cli.exe and sd-server.exe ship | |
| # unsigned today. | |
| - name: Sign Windows binaries | |
| uses: ./tooling/.github/actions/sign-windows | |
| with: | |
| path: src/build/bin | |
| azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} | |
| azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} | |
| azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} | |
| azure-account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} | |
| azure-certificate-profile: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }} | |
| - 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 | |
| # The gate, run on the finished zip rather than the build tree: packaging | |
| # copies files in after signing, so the zip is the only thing that proves | |
| # what ships. | |
| - name: Verify every PE in the bundle is signed | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Continue' | |
| $zips = @(Get-ChildItem dist -Filter *.zip -ErrorAction SilentlyContinue | | |
| ForEach-Object { $_.FullName }) | |
| if ($zips.Count -eq 0) { | |
| Write-Host '::error::no bundle in dist/; packaging produced nothing to verify' | |
| exit 1 | |
| } | |
| & tooling/.github/scripts/assert-windows-bundle-signed.ps1 -Path $zips | |
| exit $LASTEXITCODE | |
| - name: Upload bundle | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| 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@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | |
| with: | |
| path: tooling | |
| fetch-depth: 1 | |
| - name: Download bundles | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| 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" |