FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Gate the full-set artifact on failure and add the keep_artifacts hatch (#11) · unslothai/stable-diffusion.cpp@2869179 · GitHub

Unsloth SD prebuilt (CPU/Apple/CUDA) #54

Unsloth SD prebuilt (CPU/Apple/CUDA)

Unsloth SD prebuilt (CPU/Apple/CUDA) #54

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@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"

Back | FazBrowse Home | New Git URL