permissions:
- All five PR-time workflows (backend, frontend, inference smoke, tauri,
wheel) now declare permissions: contents: read at the workflow level,
matching CodeQL's default-permissions guidance and the existing pattern
in release-desktop.yml. None of these workflows write to the repo.
skipped tests:
- Repo tests (CPU) job now installs node 22 and uv, which unblocks
~60 tests that were silently skipping on CI:
- 9 tests in tests/studio/test_chat_preset_builtin_invariants.py
skipped on "node not available". Fixed in this commit; an obsolete
"unsloth_repo/" prefix in WORKDIR was also pointing the source-file
existence check at a path that no longer exists.
- tests/python/test_e2e_no_torch_sandbox.py (47), test_studio_import_no_torch.py
(29), test_tokenizers_and_torch_constraint.py (most of 42) all spawn
fresh uv venvs and self-skip when uv is missing.
- Three test_tokenizers_and_torch_constraint.py cases are deselected
because they expose a real bug in studio/backend/requirements/no-torch-runtime.txt:
the unpinned tokenizers line resolves to 0.23.1, which transformers
rejects with "tokenizers>=0.22.0,<=0.23.0 is required". Tracked
separately as a no-torch install regression.
Locally: 760 passed, 1 skipped, 23 deselected (was 694 / 67 / 23).
188 lines
7.1 KiB
YAML
188 lines
7.1 KiB
YAML
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
# End-to-end smoke: install Studio via install.sh --local --no-torch, download
|
|
# a tiny GGUF, boot Studio, log in, change password, load the model, send a
|
|
# chat completion, assert a non-empty response. Only workflow that tests "the
|
|
# app actually works".
|
|
#
|
|
# Model: Qwen3.5-2B UD-IQ3_XXS (~890 MiB) -- small enough that the cache miss
|
|
# is cheap and inference fits in the 25 min CPU-runner budget. GGUF is cached
|
|
# across runs via actions/cache.
|
|
|
|
name: Studio GGUF CI
|
|
|
|
on:
|
|
pull_request:
|
|
paths:
|
|
- 'studio/**'
|
|
- 'unsloth/**'
|
|
- 'unsloth_cli/**'
|
|
- 'install.sh'
|
|
- 'pyproject.toml'
|
|
- '.github/workflows/studio-inference-smoke.yml'
|
|
push:
|
|
branches: [main, pip]
|
|
# Manual trigger for pre-warming the GGUF cache on main, or re-running
|
|
# against an arbitrary branch without pushing a no-op commit.
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
env:
|
|
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
|
|
GGUF_FILE: Qwen3.5-2B-UD-IQ3_XXS.gguf
|
|
STUDIO_PORT: '18888'
|
|
|
|
jobs:
|
|
inference:
|
|
name: Studio boots, loads a GGUF, answers a chat completion
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 25
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Linux dependencies for llama.cpp prebuilt
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y --no-install-recommends \
|
|
libcurl4-openssl-dev libssl-dev jq
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '22'
|
|
cache: 'npm'
|
|
cache-dependency-path: studio/frontend/package-lock.json
|
|
|
|
- uses: actions/setup-python@v5
|
|
with:
|
|
python-version: '3.12'
|
|
cache: 'pip'
|
|
|
|
- name: Cache GGUF model file
|
|
id: cache-gguf
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: gguf-cache
|
|
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
|
|
|
- name: Download GGUF if cache miss
|
|
if: steps.cache-gguf.outputs.cache-hit != 'true'
|
|
run: |
|
|
# huggingface-cli was deprecated in huggingface_hub 1.13; the new CLI is `hf`.
|
|
python -m pip install --upgrade huggingface_hub hf_transfer
|
|
mkdir -p gguf-cache
|
|
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
|
hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache
|
|
|
|
- name: Install Studio (--local, --no-torch keeps the install lean)
|
|
run: |
|
|
mkdir -p logs
|
|
set -o pipefail
|
|
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
|
|
|
- name: Assert llama.cpp prebuilt was installed (no source-build fallback)
|
|
# ubuntu-latest is CPU-only x86_64, so studio/setup.sh should route
|
|
# to ggml-org/llama.cpp and grab bin-ubuntu-x64.tar.gz. A source
|
|
# build here means the routing regressed.
|
|
run: |
|
|
if grep -q "falling back to source build" logs/install.log; then
|
|
echo "::error::llama.cpp prebuilt path failed on ubuntu-latest. studio/setup.sh routing regressed; CPU-only Linux x86_64 should hit ggml-org/llama.cpp's bin-ubuntu-x64.tar.gz."
|
|
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
|
exit 1
|
|
fi
|
|
if ! grep -qE "prebuilt installed and validated|prebuilt up to date and validated" logs/install.log; then
|
|
echo "::error::install.log does not contain the success marker for the llama.cpp prebuilt path. Did setup.sh skip the prebuilt install?"
|
|
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
|
exit 1
|
|
fi
|
|
echo "llama.cpp prebuilt path used successfully"
|
|
|
|
- name: Reset auth + start Studio in the background
|
|
run: |
|
|
unsloth studio reset-password
|
|
mkdir -p logs
|
|
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
|
> logs/studio.log 2>&1 &
|
|
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
|
|
|
- name: Wait for /api/health
|
|
run: |
|
|
for i in $(seq 1 60); do
|
|
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
|
echo "ready after ${i}s"
|
|
cat /tmp/health.json
|
|
jq -e '.status == "healthy"' /tmp/health.json
|
|
exit 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo "Studio did not become healthy in 60s"
|
|
tail -200 logs/studio.log
|
|
exit 1
|
|
|
|
- name: Login + change bootstrap password
|
|
run: |
|
|
PW=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
|
NEW="CIPasswordSmoke12345!"
|
|
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
|
-H 'content-type: application/json' \
|
|
-d "{\"username\":\"unsloth\",\"password\":\"$PW\"}" | jq -r .access_token)
|
|
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
|
|
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
|
-d "{\"current_password\":\"$PW\",\"new_password\":\"$NEW\"}" > /dev/null
|
|
# Re-login to clear must_change_password flag.
|
|
NEW_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
|
-H 'content-type: application/json' \
|
|
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
|
|
echo "TOKEN=$NEW_TOKEN" >> "$GITHUB_ENV"
|
|
|
|
- name: Load the GGUF into Studio
|
|
run: |
|
|
GGUF_PATH="$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}"
|
|
ls -lh "$GGUF_PATH"
|
|
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
|
|
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
|
--max-time 600 \
|
|
-d "{\"model_path\":\"$GGUF_PATH\",\"is_lora\":false,\"max_seq_length\":2048}" \
|
|
| jq '{status, display_name, is_gguf, context_length}'
|
|
|
|
- name: Send a chat completion + assert non-empty response
|
|
run: |
|
|
RESP=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/chat/completions" \
|
|
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
|
--max-time 900 \
|
|
-d '{
|
|
"messages":[{"role":"user","content":"Say hello in one short sentence."}],
|
|
"max_tokens":40,
|
|
"stream":false
|
|
}')
|
|
echo "raw response: $RESP"
|
|
CONTENT=$(echo "$RESP" | jq -r '.choices[0].message.content // empty')
|
|
echo "model response: $CONTENT"
|
|
if [ -z "$CONTENT" ]; then
|
|
echo "::error::Empty assistant response from Studio"
|
|
exit 1
|
|
fi
|
|
|
|
- name: Stop Studio
|
|
if: always()
|
|
run: |
|
|
kill "${STUDIO_PID}" || true
|
|
sleep 2
|
|
ss -tln | grep ":${STUDIO_PORT}" || true
|
|
|
|
- name: Upload Studio + install logs on failure
|
|
if: failure()
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: studio-inference-log
|
|
path: |
|
|
logs/studio.log
|
|
logs/install.log
|
|
retention-days: 7
|