chore: import upstream snapshot with attribution
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# Inspired from https://github.com/vllm-project/vllm/blob/main/.github/CODEOWNERS
|
||||
|
||||
/unsloth/models/loader.py @danielhanchen @mmathew23
|
||||
/unsloth/models/llama.py @Datta0 @danielhanchen @mmathew23
|
||||
/unsloth/models/rl.py @Datta0 @pluesclues @danielhanchen
|
||||
/unsloth/models/rl_replacements.py @Datta0 @pluesclues @danielhanchen
|
||||
/unsloth/trainer.py @danielhanchen
|
||||
/unsloth/models/sentence_transformer.py @Etherll @danielhanchen
|
||||
/unsloth/save.py @danielhanchen
|
||||
/unsloth/tokenizer_utils.py @mmathew23 @danielhanchen
|
||||
/unsloth/chat_templates.py @danielhanchen
|
||||
/unsloth/ollama_template_mappers.py @danielhanchen
|
||||
/unsloth/kernels/moe/*.py @Datta0
|
||||
/unsloth/import_fixes.py @danielhanchen
|
||||
/unsloth/device_type.py @danielhanchen
|
||||
/unsloth/_auto_install.py @danielhanchen
|
||||
/unsloth/dataprep/*.py @danielhanchen
|
||||
/unsloth/kernels/cross_entropy_loss.py @danielhanchen
|
||||
/unsloth/kernels/fast_lora.py @danielhanchen
|
||||
/unsloth/kernels/flex_attention.py @danielhanchen
|
||||
/unsloth/kernels/fp8.py @Datta0
|
||||
/unsloth/kernels/geglu.py @danielhanchen
|
||||
/unsloth/kernels/layernorm.py @danielhanchen
|
||||
/unsloth/kernels/rms_layernorm.py @danielhanchen
|
||||
/unsloth/kernels/rope_embedding.py @danielhanchen
|
||||
/unsloth/kernels/swiglu.py @danielhanchen
|
||||
/unsloth/kernels/utils.py @danielhanchen @Datta0
|
||||
/unsloth/models/_utils.py @danielhanchen @mmathew23
|
||||
/unsloth/models/cohere.py @danielhanchen
|
||||
/unsloth/models/dpo.py @danielhanchen
|
||||
/unsloth/models/falcon_h1.py @danielhanchen
|
||||
/unsloth/models/gemma.py @danielhanchen
|
||||
/unsloth/models/gemma2.py @danielhanchen
|
||||
/unsloth/models/glm4_moe.py @Datta0
|
||||
/unsloth/models/granite.py @danielhanchen
|
||||
/unsloth/models/llama4.py @danielhanchen
|
||||
/unsloth/models/loader_utils.py @Datta0 @danielhanchen
|
||||
/unsloth/models/mapper.py @danielhanchen
|
||||
/unsloth/models/mistral.py @danielhanchen
|
||||
/unsloth/models/qwen2.py @danielhanchen
|
||||
/unsloth/models/qwen3.py @Datta0
|
||||
/unsloth/models/qwen3_moe.py @Datta0
|
||||
/unsloth/models/vision.py @mmathew23 @danielhanchen
|
||||
/unsloth/utils/attention_dispatch.py @mmathew23
|
||||
/unsloth/utils/hf_hub.py @mmathew23
|
||||
/unsloth/utils/packing.py @mmathew23
|
||||
|
||||
/cli/ @Manan17
|
||||
/studio/frontend/ @Shine1i @Manan17
|
||||
/studio/frontend/public/ @Shine1i
|
||||
/studio/backend/
|
||||
/studio/backend/core/data_recipe/
|
||||
/studio/backend/tests/ @danielhanchen
|
||||
/tests/ @danielhanchen
|
||||
/scripts/ @danielhanchen
|
||||
|
||||
# Snapshot data for the notebook linter / Colab oracle. Drift in these
|
||||
# files changes the pin floor for every Unsloth notebook, so refreshes
|
||||
# must be reviewed by the notebook owners directly. CODEOWNERS later
|
||||
# wins, so this overrides the broader /scripts/ rule above.
|
||||
/scripts/data/colab_*.txt @danielhanchen @shimmyshimmer
|
||||
/scripts/data/colab_*.json @danielhanchen @shimmyshimmer
|
||||
@@ -0,0 +1,13 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: unslothai
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # unsloth
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Bug / Issue
|
||||
about: Bug / Issue
|
||||
title: "[Bug] Please fill in your issue title here."
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
Note: Please do not remove the questions. Answer beside them.
|
||||
1. Did you update? `pip install --upgrade unsloth unsloth_zoo`
|
||||
2. `Colab` or `Kaggle` or local / cloud
|
||||
3. Number GPUs used, use `nvidia-smi`
|
||||
4. Which notebook? Please link!
|
||||
5. Which Unsloth version, TRL version, transformers version, PyTorch version?
|
||||
6. Which trainer? `SFTTrainer`, `GRPOTrainer` etc
|
||||
|
||||
```python
|
||||
Put Minimal code to reproduce error here ###Remove Hugging Face token###
|
||||
###Please make sure to check formatting properly, edit if needed.###
|
||||
```
|
||||
|
||||
🦥 You can also ask via our Reddit page: https://reddit.com/r/unsloth/
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: New features, model support, ideas
|
||||
title: "[Feature]"
|
||||
labels: feature request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
For new models, have you tried:
|
||||
```python
|
||||
from unsloth import FastModel
|
||||
model, tokenizer = FastModel.from_pretrained(
|
||||
"microsoft/Phi-4-multimodal-instruct",
|
||||
trust_remote_code = True,
|
||||
)
|
||||
from transformers import AutoModelForSequenceClassification
|
||||
model, tokenizer = FastModel.from_pretrained(
|
||||
auto_model = AutoModelForSequenceClassification,
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
# github-actions refs are git tags / SHAs, not semver -- the
|
||||
# `semver-minor-days` / `semver-patch-days` knobs are rejected
|
||||
# by Dependabot's validator for this ecosystem. Only the
|
||||
# `default-days` floor applies.
|
||||
default-days: 7
|
||||
groups:
|
||||
actions:
|
||||
patterns: ["*"]
|
||||
actions-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
# Removed a stray `package-ecosystem: "bun"` entry for
|
||||
# /studio/frontend: that path has no bun.lock / bun.lockb, so
|
||||
# Dependabot's bun ecosystem silently no-ops on it. The actual
|
||||
# lockfile committed at /studio/frontend is package-lock.json
|
||||
# (npm), and the npm entry further below already catches
|
||||
# npm_and_yarn security advisories for that directory. Version
|
||||
# updates for /studio/frontend stay suppressed (open-pull-
|
||||
# requests-limit: 0 in that entry) -- security PRs flow through
|
||||
# regardless. Add a real bun entry IF and WHEN bun.lock lands.
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/studio/backend/core/data_recipe/oxc-validator"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-minor-days: 3
|
||||
semver-patch-days: 3
|
||||
groups:
|
||||
npm-oxc-validator:
|
||||
patterns: ["*"]
|
||||
npm-oxc-validator-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
# pip + cargo grouped weekly; the *-security siblings batch
|
||||
# advisories that would otherwise each open their own PR.
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
python:
|
||||
patterns: ["*"]
|
||||
python-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/studio/src-tauri"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-minor-days: 3
|
||||
semver-patch-days: 3
|
||||
groups:
|
||||
cargo-tauri:
|
||||
patterns: ["*"]
|
||||
cargo-tauri-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
# /studio/frontend npm dependencies. Version-update PRs are
|
||||
# deliberately suppressed (open-pull-requests-limit: 0) -- the
|
||||
# frontend dep tree is large, the lockfile is the authoritative
|
||||
# pin, and `min-release-age=7` in studio/frontend/.npmrc already
|
||||
# blocks fresh tarballs at install time. Security advisories
|
||||
# arrive via GitHub's npm_and_yarn channel and are NOT capped by
|
||||
# `open-pull-requests-limit` per Dependabot's documented
|
||||
# behaviour; they flow through this entry, group together, and
|
||||
# still respect the cooldown below so we never ingest a tarball
|
||||
# that was hot-published less than 3 days ago.
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/studio/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 0
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-minor-days: 3
|
||||
semver-patch-days: 3
|
||||
groups:
|
||||
npm-frontend-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
...
|
||||
Executable
+682
@@ -0,0 +1,682 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Drive one coding agent against the running `unsloth run` server for the
|
||||
# Local Agent Guides CI. All failures from here are failure class (c)
|
||||
# "guide drift": the server preflight already passed and the agent CLI
|
||||
# already installed, so a failure here means the documented recipe in
|
||||
# unsloth_cli/commands/start.py no longer produces a working flow.
|
||||
#
|
||||
# Self-updating: for all six agents (claude, codex, hermes, openclaw,
|
||||
# opencode, pi) we obtain the exact env + command from
|
||||
# `unsloth start <agent> --no-launch` and run THAT, so a recipe change is
|
||||
# exercised automatically.
|
||||
#
|
||||
# Every agent invocation is wrapped in `timeout` so a headless-TTY prompt
|
||||
# can never hang the runner -- a timeout is reported as guide drift with a
|
||||
# distinct message.
|
||||
#
|
||||
# Usage:
|
||||
# agent-guides-drive.sh connection <agent>
|
||||
# agent-guides-drive.sh file-edit <agent>
|
||||
# agent-guides-drive.sh attribution-ab claude
|
||||
#
|
||||
# Required env (exported by serve-unsloth-run.sh):
|
||||
# UNSLOTH_BASE_URL UNSLOTH_API_KEY UNSLOTH_MODEL_ID
|
||||
# UNSLOTH_LLAMA_LOG_DIR AGENT_INVOKE_TIMEOUT UNSLOTH_SEED
|
||||
set -uo pipefail
|
||||
|
||||
MODE="${1:?usage: agent-guides-drive.sh <mode> <agent>}"
|
||||
AGENT="${2:?usage: agent-guides-drive.sh <mode> <agent>}"
|
||||
|
||||
: "${UNSLOTH_BASE_URL:?serve step did not export UNSLOTH_BASE_URL}"
|
||||
: "${UNSLOTH_API_KEY:?serve step did not export UNSLOTH_API_KEY}"
|
||||
: "${UNSLOTH_MODEL_ID:?serve step did not export UNSLOTH_MODEL_ID}"
|
||||
# Determinism (seed/temp) is applied at the server level by
|
||||
# serve-unsloth-run.sh --extra; agents inherit it through the API.
|
||||
TIMEOUT="${AGENT_INVOKE_TIMEOUT:-180}"
|
||||
|
||||
# Claude refuses --dangerously-skip-permissions outside a sandbox; the CI runner
|
||||
# IS the sandbox, so declare it (mirrors unslothai/scripts launcher.sh). Harmless
|
||||
# to the other agents, which ignore it.
|
||||
export IS_SANDBOX=1
|
||||
|
||||
# Absolute paths anchored at the repo root (this script lives in
|
||||
# .github/scripts/). Everything writes here regardless of the current working
|
||||
# directory, so the file-edit mode can `cd` into a scratch work dir without
|
||||
# breaking log/redaction writes.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
LOGS_DIR="$REPO_ROOT/logs"
|
||||
REDACTED_DIR="$REPO_ROOT/redacted-configs"
|
||||
WORKDIR_BASE="$REPO_ROOT/agent-workdir"
|
||||
CACHE_HELPER="$SCRIPT_DIR/assert-prompt-cache.sh"
|
||||
mkdir -p "$LOGS_DIR" "$REDACTED_DIR"
|
||||
CONNECT_REF="unsloth_cli/commands/start.py"
|
||||
|
||||
# Prefill-shrinking flags for Claude Code. The heavyweight agents send
|
||||
# multi-thousand-token system prompts + full tool schemas, which on a CPU-only
|
||||
# runner is minutes of prefill per model round-trip (~16 tok/s for a 4B model).
|
||||
# Replacing the ~5.7k default system prompt with a tiny one (--system-prompt-file)
|
||||
# and restricting tools cuts the prefill to a few hundred tokens so it completes
|
||||
# quickly on CPU. These only shape the request size; the start.py recipe
|
||||
# (endpoint, auth, model) is still exercised end to end.
|
||||
#
|
||||
# The bulk of Claude Code's prompt is the built-in tool JSON schemas: measured
|
||||
# via `claude -p /context`, the default prompt is ~28k tokens of which ~18k is
|
||||
# "System tools" alone. --allowedTools/--disallowedTools only gate PERMISSION to
|
||||
# call a tool; they do NOT remove its schema from what is sent to the model, so
|
||||
# the earlier whitelist left the full ~18k in the prompt and CPU prefill
|
||||
# (~16 tok/s) overran claude's own request timeout into a retry loop. --tools is
|
||||
# the flag that restricts which schemas are sent. (The ~8k "Memory files" chunk
|
||||
# is auto-loaded CLAUDE.md; the unsloth repo ships none, so it is 0 in CI.)
|
||||
#
|
||||
# Connection probe: --tools "" sends ZERO tool schemas, leaving ~20 tokens total
|
||||
# (a one-line --system-prompt-file + the user turn), which prefills instantly.
|
||||
CLAUDE_CONNECT_FLAGS=(
|
||||
--system-prompt-file "$SCRIPT_DIR/ci-connect-prompt.txt"
|
||||
--tools ""
|
||||
)
|
||||
# File-edit: the task needs the file/shell tools, so send only those schemas
|
||||
# (~2.3k tokens vs ~18k for the full set).
|
||||
CLAUDE_EDIT_FLAGS=(
|
||||
--system-prompt-file "$SCRIPT_DIR/ci-min-system-prompt.txt"
|
||||
--tools "Bash,Edit,Write,Read"
|
||||
)
|
||||
|
||||
guide_fail() {
|
||||
echo "::error::[guide drift] agent=${AGENT}: $* (preflight passed + install OK, so the documented flow in ${CONNECT_REF} drifted)." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Redact the API key from any file we are about to keep as an artifact.
|
||||
# Portable across GNU sed (Linux runners) and BSD sed (macOS), so the
|
||||
# redaction is never silently skipped.
|
||||
redact() {
|
||||
local f
|
||||
for f in "$@"; do
|
||||
[ -f "$f" ] || continue
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
|
||||
else
|
||||
sed -i '' "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Print a file to the log with the key scrubbed, without mutating it (the raw file is
|
||||
# still needed to parse the real env). Use this instead of `cat` for any transcript that
|
||||
# carries an `export UNSLOTH_API_KEY=...` line, so a live key never reaches Actions logs.
|
||||
cat_redacted() {
|
||||
sed "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$1"
|
||||
}
|
||||
|
||||
# A reply must be non-empty and free of connection/auth errors.
|
||||
assert_reply() {
|
||||
local out="$1"
|
||||
if [ ! -s "$out" ]; then
|
||||
guide_fail "agent produced an EMPTY reply"
|
||||
fi
|
||||
if grep -qiE 'connection refused|connection error|econnrefused|fetch failed|http 4[0-9][0-9]|unauthorized|invalid api key|authentication failed' "$out"; then
|
||||
guide_fail "agent reply contained a connection/auth error: $(grep -iE 'connection|unauthorized|auth|http 4' "$out" | head -1)"
|
||||
fi
|
||||
echo "[$AGENT] reply (first 20 lines):"
|
||||
head -20 "$out"
|
||||
}
|
||||
|
||||
# Run a command under a hard timeout; map 124 to a guide-drift hang message.
|
||||
run_timed() { # $1=outfile, rest=command
|
||||
local out="$1"; shift
|
||||
timeout "$TIMEOUT" "$@" > "$out" 2>&1
|
||||
local rc=$?
|
||||
if [ "$rc" -eq 124 ]; then
|
||||
redact "$out" # guide_fail exits below, so scrub the transcript here too
|
||||
echo "[$AGENT] last 40 lines before timeout:"; tail -40 "$out" 2>/dev/null || true
|
||||
guide_fail "invoke timed out after ${TIMEOUT}s (headless-TTY hang -- the recipe likely needs a non-interactive/print flag)"
|
||||
fi
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# Read a value from an `export VAR=...` line in the connect --no-launch output.
|
||||
# `unsloth start` writes each agent's session config off the user's ~ and points
|
||||
# at it through a relocation env var (CODEX_HOME / OPENCODE_CONFIG /
|
||||
# OPENCLAW_CONFIG_PATH), so the contract checks read the path from here.
|
||||
raw_env() { # $1 = var name -> value (one shlex-quote layer stripped)
|
||||
local raw="$LOGS_DIR/connect-${AGENT}.txt"
|
||||
local v; v="$(sed -n "s/^export $1=//p" "$raw" | tail -1)"
|
||||
v="${v#\'}"; v="${v%\'}"; printf '%s' "$v"
|
||||
}
|
||||
|
||||
# ── 5-agent start.py path: parse env + command from --no-launch ─────────
|
||||
# Populates globals CONNECT_ENV (export/unset lines) and CONNECT_CMD (the
|
||||
# launch command on the last printed line), and runs start.py's config
|
||||
# writers as a side effect (it writes each agent's relocated session config).
|
||||
parse_connect() {
|
||||
local raw="$LOGS_DIR/connect-${AGENT}.txt"
|
||||
# CONNECT_YOLO=1 adds --yolo. opencode/openclaw gate tool approval through their
|
||||
# config (which now prompts by default), so the file-edit test opts into auto-approval
|
||||
# here, the same intent as claude/codex's per-call bypass flags.
|
||||
local yolo=()
|
||||
[ -n "${CONNECT_YOLO:-}" ] && yolo=(--yolo)
|
||||
if ! unsloth start "$AGENT" --no-launch "${yolo[@]}" --api-key "$UNSLOTH_API_KEY" > "$raw" 2>&1; then
|
||||
cat_redacted "$raw"
|
||||
guide_fail "'unsloth start ${AGENT} --no-launch' exited non-zero"
|
||||
fi
|
||||
echo "[$AGENT] connect --no-launch printed:"; cat_redacted "$raw"
|
||||
CONNECT_ENV="$(grep -E '^(export |unset )' "$raw" || true)"
|
||||
# The launch command is the last non-export, non-status line. start.py
|
||||
# prints "Studio <url> · model <id>" and "Updated ..." status lines first.
|
||||
CONNECT_CMD="$(grep -vE '^(export |unset |Studio |Updated |Disabled |Warning|Loading)' "$raw" \
|
||||
| grep -E '[^[:space:]]' | tail -1)"
|
||||
[ -n "$CONNECT_CMD" ] || guide_fail "could not parse a launch command from connect --no-launch output"
|
||||
redact "$raw"
|
||||
}
|
||||
|
||||
# Cross-check the documented contract knobs so silent start.py changes
|
||||
# (env-var rename, wire_api flip, attribution setting drop) also fail/flag.
|
||||
crosscheck_contract() {
|
||||
local raw="$LOGS_DIR/connect-${AGENT}.txt"
|
||||
local cfg home
|
||||
case "$AGENT" in
|
||||
codex)
|
||||
grep -q 'UNSLOTH_STUDIO_AUTH_TOKEN' "$raw" \
|
||||
|| guide_fail "Codex env key is no longer UNSLOTH_STUDIO_AUTH_TOKEN (start.py _CODEX_ENV_KEY)"
|
||||
home="$(raw_env CODEX_HOME)"
|
||||
# An empty relocation var would make cfg "/config.toml" and silently
|
||||
# skip the [ -f ] contract check below; fail loudly instead.
|
||||
[ -n "$home" ] || guide_fail "CODEX_HOME missing from connect output (start.py codex())"
|
||||
cfg="$home/config.toml"
|
||||
if [ -f "$cfg" ]; then
|
||||
grep -q 'wire_api = "responses"' "$cfg" \
|
||||
|| guide_fail "Codex wire_api is no longer \"responses\" in \$CODEX_HOME/config.toml"
|
||||
cp "$cfg" "$REDACTED_DIR/codex-config.toml"
|
||||
fi
|
||||
grep -q 'codex --oss --profile unsloth_api' "$raw" \
|
||||
|| echo "::warning::Codex launch command changed from 'codex --oss --profile unsloth_api'"
|
||||
;;
|
||||
claude)
|
||||
grep -q 'ANTHROPIC_AUTH_TOKEN' "$raw" \
|
||||
|| guide_fail "Claude no longer exports ANTHROPIC_AUTH_TOKEN (start.py claude())"
|
||||
grep -q 'CLAUDE_CODE_ATTRIBUTION_HEADER' "$raw" \
|
||||
|| echo "::warning::CLAUDE_CODE_ATTRIBUTION_HEADER no longer set for the session (start.py claude())"
|
||||
;;
|
||||
hermes)
|
||||
grep -q 'UNSLOTH_API_KEY' "$raw" \
|
||||
|| guide_fail "Hermes env key is no longer UNSLOTH_API_KEY (start.py _HERMES_ENV_KEY)"
|
||||
home="$(raw_env HERMES_HOME)"
|
||||
[ -n "$home" ] || guide_fail "HERMES_HOME missing from connect output (start.py hermes())"
|
||||
cfg="$home/config.yaml"
|
||||
[ -f "$cfg" ] && cp "$cfg" "$REDACTED_DIR/hermes-config.yaml"
|
||||
;;
|
||||
openclaw)
|
||||
cfg="$(raw_env OPENCLAW_CONFIG_PATH)"
|
||||
if [ -n "$cfg" ] && [ -f "$cfg" ]; then
|
||||
grep -q '"openai-completions"' "$cfg" \
|
||||
|| echo "::warning::OpenClaw provider api is no longer 'openai-completions' (write_openclaw_config)"
|
||||
cp "$cfg" "$REDACTED_DIR/openclaw.json"
|
||||
fi
|
||||
;;
|
||||
opencode)
|
||||
cfg="$(raw_env OPENCODE_CONFIG)"
|
||||
[ -n "$cfg" ] && [ -f "$cfg" ] && cp "$cfg" "$REDACTED_DIR/opencode.json"
|
||||
;;
|
||||
pi)
|
||||
# Pi has no config-dir env var; the session is HOME-relocated, and the
|
||||
# provider config lives at $HOME/.pi/agent/models.json.
|
||||
cfg="$(raw_env HOME)/.pi/agent/models.json"
|
||||
if [ -f "$cfg" ]; then
|
||||
grep -q '"openai-completions"' "$cfg" \
|
||||
|| echo "::warning::Pi provider api is no longer 'openai-completions' (write_pi_config)"
|
||||
cp "$cfg" "$REDACTED_DIR/pi-models.json"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
redact "$REDACTED_DIR"/* 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Heavyweight agents (hermes, openclaw) bake a large system prompt + tool JSON
|
||||
# schemas into every request, which a CPU runner cannot prefill before the invoke
|
||||
# timeout. As with claude's --tools, we shrink the request from the agent's own
|
||||
# config: zero tools for the connection probe collapses the prompt to a few
|
||||
# hundred tokens, since both CLIs gate the bulk of their prompt on having tools.
|
||||
|
||||
# Hermes: an explicit empty cli toolset disables all tools (and drops the
|
||||
# tool-gated guidance blocks), so -z sends ~300 tokens instead of thousands.
|
||||
# Hermes enables its default cli toolset when the session config does not pin one,
|
||||
# so we must set platform_toolsets.cli explicitly to [] (not just append) to get
|
||||
# zero tools. That needs a YAML parser, and the runner's bare python3 has no
|
||||
# PyYAML -- but the venv that ships `unsloth` does (start.py imports yaml), so run
|
||||
# the patch with that interpreter. We patch the relocated $HERMES_HOME/config.yaml
|
||||
# that `unsloth start` printed, not the user's ~/.hermes.
|
||||
# (-z reads platform_toolsets.cli; --ignore-rules is a no-op under -z.)
|
||||
patch_hermes_tools() { # $1 = none|default
|
||||
# Check the raw var BEFORE appending /config.yaml: the joined path is never
|
||||
# empty, so the old guard could not fire and the patcher would die on
|
||||
# "/config.yaml" with a bare traceback instead of this clear failure.
|
||||
local home; home="$(raw_env HERMES_HOME)"
|
||||
[ -n "$home" ] || guide_fail "Hermes HERMES_HOME missing from connect output (start.py hermes())"
|
||||
local cfg; cfg="$home/config.yaml"
|
||||
# Find a python that can import yaml. The runner's bare python3 cannot, but the
|
||||
# interpreter in the `unsloth` console-script shebang provably can (it runs
|
||||
# start.py's write_hermes_config, which imports yaml). Try that first, then
|
||||
# any python on PATH, then the venv sibling, picking the first with PyYAML.
|
||||
local cand py="" shebang
|
||||
shebang="$(head -1 "$(command -v unsloth)" 2>/dev/null | sed -n 's/^#![[:space:]]*//p' | awk '{print $1}')"
|
||||
for cand in "$shebang" python3 python "$(dirname "$(command -v unsloth)")/python"; do
|
||||
[ -n "$cand" ] || continue
|
||||
{ [ -x "$cand" ] || command -v "$cand" >/dev/null 2>&1; } || continue
|
||||
if "$cand" -c 'import yaml' 2>/dev/null; then py="$cand"; break; fi
|
||||
done
|
||||
[ -n "$py" ] || guide_fail "could not find a python with PyYAML to patch the hermes session config"
|
||||
echo "[hermes] patching $cfg with $py"
|
||||
"$py" - "$1" "$cfg" <<'PY'
|
||||
import os, sys
|
||||
import yaml
|
||||
mode = sys.argv[1]
|
||||
p = sys.argv[2]
|
||||
cfg = (yaml.safe_load(open(p)) or {}) if os.path.exists(p) else {}
|
||||
ts = cfg.get("platform_toolsets")
|
||||
if not isinstance(ts, dict):
|
||||
ts = cfg["platform_toolsets"] = {}
|
||||
if mode == "none":
|
||||
ts["cli"] = [] # explicit empty list -> zero tools (not "defaults")
|
||||
else:
|
||||
ts.pop("cli", None) # file-edit needs real tools -> restore defaults
|
||||
with open(p, "w") as fh:
|
||||
yaml.safe_dump(cfg, fh, sort_keys=False)
|
||||
print(f"[hermes] platform_toolsets.cli = {ts.get('cli', 'default')}")
|
||||
PY
|
||||
}
|
||||
|
||||
# OpenClaw: 'openclaw agent' has no tool/prompt flags, so we define a 'ci' agent
|
||||
# in openclaw.json. tools.deny ["*"] sends zero tool schemas (deny always wins)
|
||||
# for the connection probe; contextInjection "never" + defaults.skipBootstrap
|
||||
# drop the auto-injected AGENTS.md/SOUL.md bootstrap (the bulk of the prompt) for
|
||||
# both modes. --agent must reference a defined agent, so write it before invoking.
|
||||
patch_openclaw_agent() { # $1 = notools|tools
|
||||
# OpenClaw reads its config from the relocated OPENCLAW_CONFIG_PATH that
|
||||
# `unsloth start` printed, so patch THAT file (not the user's ~/.openclaw).
|
||||
local cfg; cfg="$(raw_env OPENCLAW_CONFIG_PATH)"
|
||||
[ -n "$cfg" ] || guide_fail "OpenClaw OPENCLAW_CONFIG_PATH missing from connect output (start.py openclaw())"
|
||||
python3 - "$1" "$cfg" <<'PY'
|
||||
import os, sys, json
|
||||
mode = sys.argv[1]
|
||||
p = sys.argv[2]
|
||||
cfg = json.load(open(p)) if os.path.exists(p) else {}
|
||||
agents = cfg.setdefault("agents", {})
|
||||
agents.setdefault("defaults", {})["skipBootstrap"] = True
|
||||
lst = [a for a in agents.get("list", []) if a.get("id") != "ci"]
|
||||
agent = {"id": "ci", "contextInjection": "never"}
|
||||
if mode == "notools":
|
||||
agent["tools"] = {"deny": ["*"]}
|
||||
lst.append(agent)
|
||||
agents["list"] = lst
|
||||
with open(p, "w") as fh:
|
||||
json.dump(cfg, fh, indent=2)
|
||||
print(f"[openclaw] agent ci tools = {agent.get('tools', 'default')}")
|
||||
PY
|
||||
}
|
||||
|
||||
# Build an invoke script that applies start.py's env then runs the launch
|
||||
# command (with extra args appended) under bash. We do NOT eval connect's env
|
||||
# into this shell; we write it into a one-shot script so the export/unset
|
||||
# semantics are exactly what start.py printed. The script path is absolute
|
||||
# so it is valid even when the caller has cd'd into a scratch work dir.
|
||||
invoke_via_connect() { # $1=outfile, rest=extra args appended to the command
|
||||
local out="$1"; shift
|
||||
local script="$LOGS_DIR/invoke-${AGENT}.sh"
|
||||
local real; real="$(mktemp)"
|
||||
# CONNECT_ENV_EXTRA / CONNECT_CMD_OVERRIDE let a caller (attribution-ab) flip a
|
||||
# session knob without editing the user's config; empty -> use what start.py emitted.
|
||||
local cmd="${CONNECT_CMD_OVERRIDE:-$CONNECT_CMD}"
|
||||
{
|
||||
echo "set -uo pipefail"
|
||||
echo "$CONNECT_ENV"
|
||||
[ -n "${CONNECT_ENV_EXTRA:-}" ] && echo "$CONNECT_ENV_EXTRA"
|
||||
# Append extra args (the prompt / flags) to the launch command verbatim.
|
||||
printf '%s' "$cmd"
|
||||
local a
|
||||
for a in "$@"; do printf ' %q' "$a"; done
|
||||
printf '\n'
|
||||
} > "$real"
|
||||
# Upload a REDACTED copy of the script, but EXECUTE the un-redacted one from a
|
||||
# temp path outside the artifact dir. Redacting the script we run would turn
|
||||
# the real `export TOKEN=sk-...` line into `export TOKEN=<REDACTED>`, which is
|
||||
# invalid bash (the `<`/`>` are redirections) and silently breaks every agent.
|
||||
# Writing the redacted copy up front keeps the key out of the artifact even if
|
||||
# the run times out (run_timed exits before returning here).
|
||||
cp "$real" "$script"; redact "$script"
|
||||
# The connect one-liner now carries the key as an inline env assignment; scrub it on
|
||||
# the way to the log (the executed $real keeps the live value).
|
||||
echo "[$AGENT] invoking (timeout ${TIMEOUT}s): ${cmd//${UNSLOTH_API_KEY}/<REDACTED>} $*"
|
||||
run_timed "$out" bash "$real"
|
||||
local rc=$?
|
||||
rm -f "$real"
|
||||
redact "$out" # the transcript can echo the token; scrub before upload
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════
|
||||
case "$MODE" in
|
||||
# ── connection: trivial prompt, assert a non-empty, error-free reply ────
|
||||
connection)
|
||||
PROMPT='Reply with exactly the single word: pong'
|
||||
OUT="$LOGS_DIR/${AGENT}-connection.txt"
|
||||
parse_connect
|
||||
crosscheck_contract
|
||||
# claude/codex run in print mode via the flags start.py emits
|
||||
# (claude -p / codex exec). For agents whose default subcommand prints
|
||||
# to stdout we pass the prompt through ctx.args.
|
||||
case "$AGENT" in
|
||||
claude) invoke_via_connect "$OUT" "${CLAUDE_CONNECT_FLAGS[@]}" -p "$PROMPT" ;;
|
||||
codex) invoke_via_connect "$OUT" exec --dangerously-bypass-approvals-and-sandbox "$PROMPT" ;;
|
||||
opencode) invoke_via_connect "$OUT" run "$PROMPT" ;;
|
||||
pi) invoke_via_connect "$OUT" -p "$PROMPT" ;;
|
||||
hermes) patch_hermes_tools none
|
||||
invoke_via_connect "$OUT" -z "$PROMPT" ;;
|
||||
openclaw) patch_openclaw_agent notools
|
||||
CONNECT_CMD_OVERRIDE=openclaw invoke_via_connect "$OUT" agent --local --agent ci \
|
||||
--model "unsloth/${UNSLOTH_MODEL_ID}" --message "$PROMPT" ;;
|
||||
*) invoke_via_connect "$OUT" "$PROMPT" ;;
|
||||
esac
|
||||
# A non-zero exit from the documented launch command is drift even if it
|
||||
# printed something: a benign-looking "command not found" / usage dump would
|
||||
# otherwise slip past assert_reply (which only flags empty/error-keyword text).
|
||||
rc=$?
|
||||
[ "$rc" -eq 0 ] || guide_fail "the documented launch command exited non-zero (rc=$rc) -- see the transcript above"
|
||||
assert_reply "$OUT"
|
||||
echo "[$AGENT] connection OK"
|
||||
;;
|
||||
|
||||
# ── file-edit: deterministic 2-turn hello.py test (Qwen3.5-2B) ──────────
|
||||
file-edit)
|
||||
WORK="$WORKDIR_BASE/${AGENT}"
|
||||
rm -rf "$WORK"; mkdir -p "$WORK"
|
||||
OUT1="$LOGS_DIR/${AGENT}-fileedit-turn1.txt"
|
||||
OUT2="$LOGS_DIR/${AGENT}-fileedit-turn2.txt"
|
||||
T1='Create a file named hello.py in the current directory whose entire contents are a single line: print("Hello"). Do not run it.'
|
||||
T2='Run hello.py with python and show me the exact output.'
|
||||
|
||||
# The start.py recipe writers + crosscheck must see the repo; run them
|
||||
# from the repo root BEFORE cd-ing into the scratch work dir. opencode/openclaw
|
||||
# gate tool approval through their config (prompting by default), so file-edit
|
||||
# opts them into auto-approval to run edits/commands headlessly.
|
||||
case "$AGENT" in opencode|openclaw) CONNECT_YOLO=1 ;; esac
|
||||
parse_connect
|
||||
crosscheck_contract
|
||||
# File-edit needs real tools, so we cannot zero them as in connection.
|
||||
# hermes keeps default tools; openclaw still strips its AGENTS.md/SOUL.md
|
||||
# bootstrap (the largest prompt chunk) via the 'ci' agent. The scratch work
|
||||
# dir is empty, so no project context files are auto-loaded either.
|
||||
case "$AGENT" in
|
||||
hermes) patch_hermes_tools default ;;
|
||||
openclaw) patch_openclaw_agent tools ;;
|
||||
esac
|
||||
|
||||
# Drive from inside the work dir so the agent edits files there. All log
|
||||
# writes use absolute $LOGS_DIR, so cwd does not matter for them.
|
||||
cd "$WORK" || guide_fail "could not enter work dir $WORK"
|
||||
|
||||
invoke_turn() { # $1=outfile $2=continue? $3=prompt
|
||||
local out="$1" cont="$2" prompt="$3"
|
||||
case "$AGENT" in
|
||||
pi)
|
||||
# Pi continues the previous session with -c; provider/model come from
|
||||
# the parsed `unsloth start pi` recipe (CONNECT_CMD), not hardcoded here.
|
||||
if [ "$cont" = "continue" ]; then
|
||||
invoke_via_connect "$out" -p --continue "$prompt"
|
||||
else
|
||||
invoke_via_connect "$out" -p "$prompt"
|
||||
fi ;;
|
||||
claude)
|
||||
# --dangerously-skip-permissions lets headless claude actually use the
|
||||
# Write/Bash tools (otherwise it blocks on an approval prompt and emits
|
||||
# nothing). IS_SANDBOX=1 (exported above) authorizes it.
|
||||
if [ "$cont" = "continue" ]; then
|
||||
invoke_via_connect "$out" "${CLAUDE_EDIT_FLAGS[@]}" --dangerously-skip-permissions -p --continue "$prompt"
|
||||
else
|
||||
invoke_via_connect "$out" "${CLAUDE_EDIT_FLAGS[@]}" --dangerously-skip-permissions -p "$prompt"
|
||||
fi ;;
|
||||
codex)
|
||||
# --dangerously-bypass-approvals-and-sandbox gives codex exec
|
||||
# workspace-write (default is read-only -> cannot create hello.py) and
|
||||
# skips the bubblewrap sandbox that the runner lacks.
|
||||
if [ "$cont" = "continue" ]; then
|
||||
invoke_via_connect "$out" exec --dangerously-bypass-approvals-and-sandbox resume --last "$prompt"
|
||||
else
|
||||
invoke_via_connect "$out" exec --dangerously-bypass-approvals-and-sandbox "$prompt"
|
||||
fi ;;
|
||||
opencode) invoke_via_connect "$out" run "$prompt" ;;
|
||||
hermes) invoke_via_connect "$out" -z "$prompt" ;;
|
||||
openclaw) CONNECT_CMD_OVERRIDE=openclaw invoke_via_connect "$out" agent --local --agent ci \
|
||||
--model "unsloth/${UNSLOTH_MODEL_ID}" --message "$prompt" ;;
|
||||
*) invoke_via_connect "$out" "$prompt" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Turn 1: create hello.py.
|
||||
invoke_turn "$OUT1" fresh "$T1"
|
||||
# Fail on a non-zero agent exit before trusting side effects: an agent can
|
||||
# error out (API/tool failure) yet leave a plausible file/transcript behind,
|
||||
# which would otherwise slip past the assertions below (mirrors connection).
|
||||
rc=$?
|
||||
[ "$rc" -eq 0 ] || { echo "[$AGENT] turn-1 transcript:"; tail -40 "$OUT1" 2>/dev/null || true; \
|
||||
guide_fail "turn 1 (create hello.py) exited non-zero (rc=$rc)"; }
|
||||
|
||||
# Hard assertions on the side effect (the real test): file + content + run.
|
||||
if [ ! -f hello.py ]; then
|
||||
echo "[$AGENT] turn-1 transcript:"; tail -40 "$OUT1" 2>/dev/null || true
|
||||
guide_fail "turn 1 did not create hello.py"
|
||||
fi
|
||||
grep -q 'Hello' hello.py || guide_fail "hello.py does not contain 'Hello'"
|
||||
RUN_OUT="$(python3 hello.py 2>&1 || true)"
|
||||
[ "$RUN_OUT" = "Hello" ] || guide_fail "python3 hello.py printed '$RUN_OUT', expected exactly 'Hello'"
|
||||
echo "[$AGENT] turn 1 OK (file created, prints 'Hello')"
|
||||
|
||||
# Turn 2: same cwd + session continuation; assert the agent's run output
|
||||
# contains Hello. Narration drift is WARN-only, missing output is a hard fail.
|
||||
invoke_turn "$OUT2" continue "$T2"
|
||||
rc=$?
|
||||
[ "$rc" -eq 0 ] || { echo "[$AGENT] turn-2 transcript:"; tail -60 "$OUT2" 2>/dev/null || true; \
|
||||
guide_fail "turn 2 (run hello.py) exited non-zero (rc=$rc)"; }
|
||||
if grep -q 'Hello' "$OUT2"; then
|
||||
echo "[$AGENT] turn 2 OK (run output contains 'Hello')"
|
||||
else
|
||||
echo "[$AGENT] turn-2 transcript:"; tail -60 "$OUT2" 2>/dev/null || true
|
||||
guide_fail "turn 2 run/bash output did not contain 'Hello'"
|
||||
fi
|
||||
cd "$REPO_ROOT" || true
|
||||
echo "[$AGENT] file-edit OK"
|
||||
;;
|
||||
|
||||
# ── attribution-ab: Claude Code KV-cache HIT vs MISS ────────────────────
|
||||
attribution-ab)
|
||||
[ "$AGENT" = "claude" ] || guide_fail "attribution-ab only applies to claude"
|
||||
# The llama-server log filename uses the INTERNAL random llama.cpp port,
|
||||
# not STUDIO_PORT, so we never glob by port: assert-prompt-cache.sh picks
|
||||
# the newest llama-*.log and we slice it by a byte offset (`mark`) captured
|
||||
# right before the measured turn, so an earlier turn's reuse can't leak in.
|
||||
LLAMA_LOG_DIR="${UNSLOTH_LLAMA_LOG_DIR:-$HOME/.unsloth/studio/logs/llama-server}"
|
||||
export LLAMA_LOG_DIR
|
||||
parse_connect # prints session env + suppression flags (no ~/.claude write)
|
||||
crosscheck_contract
|
||||
PROMPT='Reply with exactly the single word: pong'
|
||||
|
||||
# Phase A: the suppression start.py ships (CLAUDE_CODE_ATTRIBUTION_HEADER=0 +
|
||||
# --exclude-dynamic-system-prompt-sections + --settings overlay) -> expect a
|
||||
# HIT on the continued turn, since the system-prompt prefix is stable.
|
||||
invoke_via_connect "$LOGS_DIR/claude-ab-hit-1.txt" -p "$PROMPT" # turn 1 primes
|
||||
FROM_HIT="$(bash "$CACHE_HELPER" mark)" # offset before turn 2
|
||||
invoke_via_connect "$LOGS_DIR/claude-ab-hit-2.txt" -p --continue "$PROMPT again"
|
||||
CACHE_LOG_FROM="$FROM_HIT" bash "$CACHE_HELPER" log HIT
|
||||
|
||||
# Phase B: vanilla Claude with the header ENABLED -> expect a MISS. We flip
|
||||
# the env var to 1 and strip the suppression flags from the launch command
|
||||
# (without them the dynamic attribution line is included and changes every
|
||||
# turn, so the shared prefix moves and the KV cache is invalidated, ~90%
|
||||
# slower). This is session-only: nothing is written to ~/.claude.
|
||||
CONNECT_ENV_EXTRA='export CLAUDE_CODE_ATTRIBUTION_HEADER=1'
|
||||
CONNECT_CMD_OVERRIDE="$(printf '%s' "$CONNECT_CMD" \
|
||||
| sed -E "s/ --exclude-dynamic-system-prompt-sections//; s/ --settings '[^']*'//")"
|
||||
invoke_via_connect "$LOGS_DIR/claude-ab-miss-1.txt" -p "$PROMPT"
|
||||
FROM_MISS="$(bash "$CACHE_HELPER" mark)"
|
||||
invoke_via_connect "$LOGS_DIR/claude-ab-miss-2.txt" -p --continue "$PROMPT again"
|
||||
CACHE_LOG_FROM="$FROM_MISS" bash "$CACHE_HELPER" log MISS
|
||||
unset CONNECT_ENV_EXTRA CONNECT_CMD_OVERRIDE
|
||||
echo "[claude] attribution A/B OK (suppressed HIT, header=1 MISS)"
|
||||
;;
|
||||
|
||||
# ── resume: does a launched agent's session survive exit and resume? ────
|
||||
# Unlike the other modes, this drives the real LAUNCH path (`unsloth start
|
||||
# <agent> ...`, the interactive default), not the --no-launch recipe. That
|
||||
# path relocates each agent's home to a throwaway temp dir wiped on exit, so
|
||||
# a session cannot be resumed -- unless --persist routes it to the stable
|
||||
# Unsloth agents dir instead. We run one headless turn per pass and check
|
||||
# whether the turn left a session in a persistent store (deterministic, no
|
||||
# reliance on the model recalling anything), for a baseline pass and a
|
||||
# --persist pass, and assert the expected split for this agent.
|
||||
resume)
|
||||
CODEWORD="PLATYPUS7"
|
||||
T1="Remember this codeword for later: ${CODEWORD}. Reply with just the word OK."
|
||||
T2="What codeword did I ask you to remember? Reply with just that word."
|
||||
WORK="$WORKDIR_BASE/${AGENT}-resume"
|
||||
|
||||
# STABLE_HOME: the stable dir that --no-launch (and --persist) relocate to.
|
||||
# Read it from a --no-launch probe (which also writes the agent's config
|
||||
# there). codex/pi relocate their whole home/HOME here; opencode/claude keep
|
||||
# their session data in a fixed user dir, so STABLE_HOME stays empty for them.
|
||||
parse_connect
|
||||
case "$AGENT" in
|
||||
codex) STABLE_HOME="$(raw_env CODEX_HOME)" ;;
|
||||
pi) STABLE_HOME="$(raw_env HOME)" ;;
|
||||
*) STABLE_HOME="" ;;
|
||||
esac
|
||||
|
||||
# The persistent stores a session would land in if it were NOT wiped. We
|
||||
# count files here before/after each turn; a positive delta means the
|
||||
# session persisted (is resumable), zero means it went to a wiped temp dir.
|
||||
resume_tracked_dirs() {
|
||||
case "$AGENT" in
|
||||
codex) printf '%s\n' "$HOME/.codex" ;;
|
||||
opencode) printf '%s\n' "$HOME/.local/share/opencode" "$HOME/.config/opencode" ;;
|
||||
claude) printf '%s\n' "$HOME/.claude" ;;
|
||||
pi) printf '%s\n' "$HOME/.pi" ;;
|
||||
*) : ;;
|
||||
esac
|
||||
[ -n "$STABLE_HOME" ] && printf '%s\n' "$STABLE_HOME"
|
||||
}
|
||||
count_session_files() {
|
||||
local total=0 d n
|
||||
while IFS= read -r d; do
|
||||
[ -n "$d" ] && [ -d "$d" ] || continue
|
||||
n="$(find "$d" -type f 2>/dev/null | wc -l)"; total=$((total + n))
|
||||
done < <(resume_tracked_dirs)
|
||||
echo "$total"
|
||||
}
|
||||
|
||||
# The headless first-turn subcommand per agent (mirrors file-edit's map),
|
||||
# forwarded verbatim through the launch path as passthrough args.
|
||||
set_t1_cmd() {
|
||||
case "$AGENT" in
|
||||
claude) T1_CMD=("${CLAUDE_CONNECT_FLAGS[@]}" -p "$T1") ;;
|
||||
codex) T1_CMD=(exec "$T1") ;;
|
||||
opencode) T1_CMD=(run "$T1") ;;
|
||||
pi) T1_CMD=(-p "$T1") ;;
|
||||
*) guide_fail "resume mode does not cover agent '$AGENT'" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Run one headless turn through the launch path. $1=outfile, $2="" or
|
||||
# "--persist", rest = the agent subcommand. --yolo auto-approves so no tool
|
||||
# prompt can hang; --api-key attaches to the already-served CI model.
|
||||
launch_turn() {
|
||||
local out="$1" rflag="$2"; shift 2
|
||||
local flag=(); [ -n "$rflag" ] && flag=("$rflag")
|
||||
run_timed "$out" unsloth start "$AGENT" "${flag[@]}" --yolo \
|
||||
--api-key "$UNSLOTH_API_KEY" "$@"
|
||||
local rc=$?
|
||||
redact "$out"
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
# One pass: fresh work dir, one planting turn, set RESULT to PERSISTED/WIPED
|
||||
# from the session-store delta. Runs in the main shell (not a command
|
||||
# substitution) so a hang's guide_fail actually fails the job and the
|
||||
# progress lines reach the CI log. $1 = "" (baseline) or "--persist".
|
||||
RESULT=""
|
||||
run_pass() {
|
||||
local rflag="$1" label="baseline"
|
||||
[ -n "$rflag" ] && label="resume"
|
||||
rm -rf "$WORK"; mkdir -p "$WORK"
|
||||
set_t1_cmd
|
||||
local out="$LOGS_DIR/${AGENT}-resume-${label}.txt"
|
||||
local before after rc
|
||||
before="$(count_session_files)"
|
||||
pushd "$WORK" >/dev/null || guide_fail "could not enter work dir $WORK"
|
||||
launch_turn "$out" "$rflag" "${T1_CMD[@]}"; rc=$?
|
||||
popd >/dev/null || true
|
||||
after="$(count_session_files)"
|
||||
echo "[$AGENT] ${label}: session files ${before} -> ${after} (rc=${rc})"
|
||||
# The turn must succeed for the delta to mean anything: an agent that writes a
|
||||
# session file then errors would otherwise be misread as PERSISTED. Mirror the
|
||||
# file-edit mode and fail the pass on a non-zero launch (the flagship codex recall
|
||||
# below stays WARN-only, driven by its own launch_turn calls).
|
||||
[ "$rc" -eq 0 ] || { echo "[$AGENT] ${label} transcript (tail):"; tail -30 "$out" 2>/dev/null || true; \
|
||||
guide_fail "resume ${label} turn for ${AGENT} exited non-zero (rc=${rc})"; }
|
||||
if [ "$after" -gt "$before" ]; then RESULT="PERSISTED"; else RESULT="WIPED"; fi
|
||||
}
|
||||
|
||||
run_pass ""; BASELINE="$RESULT"
|
||||
# Only the temp-dir agents (codex/pi) need the --persist pass to prove the fix.
|
||||
# opencode/claude persist either way, so the baseline already proves it and a
|
||||
# second full CPU turn only risks a timeout; skip it for them.
|
||||
case "$AGENT" in
|
||||
codex|pi) run_pass "--persist"; RESUME="$RESULT" ;;
|
||||
*) RESUME="n/a (persists either way)" ;;
|
||||
esac
|
||||
|
||||
# Expected: codex/pi relocate their whole home to the temp dir, so a plain
|
||||
# launch is WIPED and only --persist PERSISTS. opencode/claude keep their
|
||||
# session data in a fixed user dir, so the baseline already PERSISTS.
|
||||
case "$AGENT" in
|
||||
codex|pi) EXPECT_BASELINE="WIPED" ;;
|
||||
opencode|claude) EXPECT_BASELINE="PERSISTED" ;;
|
||||
esac
|
||||
|
||||
echo "──────────────────────────────────────────────"
|
||||
echo "[$AGENT] RESUME EXPERIMENT"
|
||||
echo " baseline (unsloth start ${AGENT}): ${BASELINE} (expected ${EXPECT_BASELINE})"
|
||||
echo " with --persist (unsloth start ${AGENT} --persist): ${RESUME}"
|
||||
echo "──────────────────────────────────────────────"
|
||||
|
||||
[ "$BASELINE" = "$EXPECT_BASELINE" ] \
|
||||
|| guide_fail "baseline resume behavior for ${AGENT} was ${BASELINE}, expected ${EXPECT_BASELINE}"
|
||||
case "$AGENT" in
|
||||
codex|pi)
|
||||
[ "$RESUME" = "PERSISTED" ] \
|
||||
|| guide_fail "--persist did not persist ${AGENT}'s session (got ${RESUME}); the session dir is still not stable" ;;
|
||||
esac
|
||||
|
||||
# Flagship behavioral proof (codex only, WARN-only): after a --persist plant,
|
||||
# resume the session and check the model actually recalls the codeword. A
|
||||
# miss is not a failure (the CI model is small); the mechanism gate above is
|
||||
# the real assertion.
|
||||
if [ "$AGENT" = "codex" ]; then
|
||||
rm -rf "$WORK"; mkdir -p "$WORK"
|
||||
( cd "$WORK" && launch_turn "$LOGS_DIR/codex-resume-plant.txt" "--persist" exec "$T1" ) || true
|
||||
( cd "$WORK" && launch_turn "$LOGS_DIR/codex-resume-recall.txt" "--persist" exec resume --last "$T2" ) || true
|
||||
if grep -q "$CODEWORD" "$LOGS_DIR/codex-resume-recall.txt" 2>/dev/null; then
|
||||
echo "[codex] behavioral recall HIT: resumed session remembered ${CODEWORD}"
|
||||
else
|
||||
echo "::warning::[codex] behavioral recall MISS (small CI model); mechanism gate still passed"
|
||||
fi
|
||||
fi
|
||||
echo "[$AGENT] resume OK"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "agent-guides-drive.sh: unknown mode '$MODE'" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Install one coding-agent CLI for the Local Agent Guides CI. Isolated as
|
||||
# failure class (b) "agent package install failed": npm/curl flakiness here
|
||||
# is the single biggest source of false reds, so installs retry with
|
||||
# backoff and the only ::error:: this script can emit is class (b). The
|
||||
# install recipes mirror the install_hint strings in
|
||||
# unsloth_cli/commands/start.py at HEAD.
|
||||
#
|
||||
# Usage: agent-guides-install.sh <agent>
|
||||
# agent in: claude codex hermes openclaw opencode pi
|
||||
set -uo pipefail
|
||||
|
||||
AGENT="${1:?usage: agent-guides-install.sh <agent>}"
|
||||
mkdir -p logs
|
||||
LOG="logs/install-${AGENT}.log"
|
||||
|
||||
install_fail() {
|
||||
echo "::error::[agent install failed] agent=${AGENT}: $* (class (b): the agent CLI did not install; not a server or guide problem)." >&2
|
||||
echo "---- tail $LOG ----" >&2
|
||||
tail -60 "$LOG" 2>/dev/null || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
# npm registry flakiness is common in CI; retry 3x with linear backoff.
|
||||
# Extra npm flags may precede the package (e.g. npm_retry --ignore-scripts pkg).
|
||||
npm_retry() {
|
||||
local i
|
||||
for i in 1 2 3; do
|
||||
if npm install -g "$@" >> "$LOG" 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
echo "[install] npm install -g $* attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG"
|
||||
sleep "$((i * 10))"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# curl|bash installers, retried at the curl layer. We download to a temp file
|
||||
# first and only execute on a fully successful fetch, so a truncated download
|
||||
# (network hiccup mid-stream) can never run a half-written installer.
|
||||
curl_bash() {
|
||||
local url="$1"; shift
|
||||
local i tmp
|
||||
tmp="$(mktemp)"
|
||||
for i in 1 2 3; do
|
||||
if curl -fsSL --retry 3 --retry-delay 5 "$url" -o "$tmp" 2>>"$LOG" \
|
||||
&& bash "$tmp" "$@" >> "$LOG" 2>&1; then
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
echo "[install] curl|bash $url attempt $i failed; backing off $((i * 10))s" | tee -a "$LOG"
|
||||
sleep "$((i * 10))"
|
||||
done
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "[install] agent=$AGENT (log=$LOG)"
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
# start.py install_hint: curl -fsSL https://claude.ai/install.sh | bash
|
||||
curl_bash "https://claude.ai/install.sh" || install_fail "claude installer failed"
|
||||
# The installer drops the binary under ~/.local/bin.
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
;;
|
||||
codex)
|
||||
# start.py install_hint: npm install -g @openai/codex
|
||||
npm_retry "@openai/codex" || install_fail "npm install -g @openai/codex failed"
|
||||
;;
|
||||
opencode)
|
||||
# start.py install_hint: npm install -g opencode-ai
|
||||
npm_retry "opencode-ai" || install_fail "npm install -g opencode-ai failed"
|
||||
;;
|
||||
openclaw)
|
||||
# start.py install_hint: curl -fsSL https://openclaw.ai/install.sh | bash
|
||||
# npm is the more deterministic path in CI and matches the agent's docs;
|
||||
# fall back to the start.py curl installer if the npm tag is missing.
|
||||
if ! npm_retry "openclaw@latest"; then
|
||||
curl_bash "https://openclaw.ai/install.sh" || install_fail "openclaw install failed (npm + curl)"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
fi
|
||||
;;
|
||||
hermes)
|
||||
# start.py install_hint:
|
||||
# curl -fsSL .../NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
curl_bash "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh" \
|
||||
--non-interactive --skip-setup --skip-browser --no-skills \
|
||||
|| install_fail "hermes installer failed"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
;;
|
||||
pi)
|
||||
# start.py install_hint: npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
||||
# (--ignore-scripts matches Pi's documented recipe; exercising the exact hint
|
||||
# catches guide drift). The CLI moved from the now-deprecated @mariozechner
|
||||
# scope to @earendil-works (the old scope is frozen, so installing it would
|
||||
# test a stale Pi against the API).
|
||||
npm_retry --ignore-scripts "@earendil-works/pi-coding-agent" \
|
||||
|| install_fail "npm install -g --ignore-scripts @earendil-works/pi-coding-agent failed"
|
||||
;;
|
||||
*)
|
||||
install_fail "unknown agent '$AGENT'"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "[install] OK for $AGENT"
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Assert Studio installed a llama.cpp that loads and runs on THIS macOS. Tests
|
||||
# the contract that matters (binaries load and their minimum-OS is <= this host)
|
||||
# instead of the old "did install.sh fall back to a source build?" grep, since a
|
||||
# source build with a correct deployment target is a valid outcome.
|
||||
set -uo pipefail
|
||||
|
||||
UNSLOTH_HOME="${STUDIO_HOME:-$HOME/.unsloth}"
|
||||
LLAMA_DIR="${LLAMA_CPP_DIR:-$UNSLOTH_HOME/llama.cpp}"
|
||||
BIN_DIR="$LLAMA_DIR/build/bin"
|
||||
|
||||
fail() {
|
||||
echo "::error::$*"
|
||||
if [ -f logs/install.log ]; then
|
||||
echo "---- install.log (llama.cpp lines) ----"
|
||||
grep -E "llama-prebuilt|llama\.cpp|macos prebuilt|falling back" logs/install.log | tail -80 || true
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
SERVER="$(find "$LLAMA_DIR" -type f -name 'llama-server' 2>/dev/null | head -1)"
|
||||
QUANT="$(find "$LLAMA_DIR" -type f -name 'llama-quantize' 2>/dev/null | head -1)"
|
||||
[ -n "$SERVER" ] || fail "llama-server not found under $LLAMA_DIR after install"
|
||||
[ -n "$QUANT" ] || fail "llama-quantize not found under $LLAMA_DIR after install"
|
||||
|
||||
HOST_VER="$(sw_vers -productVersion 2>/dev/null || echo '0')"
|
||||
HOST_MAJOR="${HOST_VER%%.*}"
|
||||
|
||||
# Static minimum-OS check on every Mach-O we ship. vtool ships with the Xcode
|
||||
# command line tools, which GitHub macOS runners always have; if it is somehow
|
||||
# missing we skip the static check and rely on the runtime launch below.
|
||||
if command -v vtool >/dev/null 2>&1; then
|
||||
while IFS= read -r macho; do
|
||||
[ -n "$macho" ] || continue
|
||||
minos="$(vtool -show-build "$macho" 2>/dev/null | awk '/minos/{print $2; exit}')"
|
||||
[ -n "$minos" ] || continue
|
||||
min_major="${minos%%.*}"
|
||||
if [ "$min_major" -gt "$HOST_MAJOR" ] 2>/dev/null; then
|
||||
fail "$(basename "$macho") is built for macOS $minos but this runner is macOS $HOST_VER (prebuilt is newer than the host)"
|
||||
fi
|
||||
done < <(find "$BIN_DIR" -type f \( -name '*.dylib' -o -name 'llama-server' -o -name 'llama-quantize' \) 2>/dev/null)
|
||||
fi
|
||||
|
||||
# Runtime launch: --version forces dyld to load every linked dylib (including
|
||||
# libggml-metal.dylib). A missing Metal symbol or too-new binary fails here.
|
||||
if ! "$SERVER" --version >/tmp/llama-server-version.txt 2>&1; then
|
||||
echo "---- llama-server --version output ----"
|
||||
cat /tmp/llama-server-version.txt || true
|
||||
fail "llama-server failed to launch on macOS $HOST_VER (dyld load / symbol error)"
|
||||
fi
|
||||
|
||||
echo "llama.cpp load validation passed on macOS $HOST_VER"
|
||||
echo " server: $SERVER"
|
||||
sed -n '1,4p' /tmp/llama-server-version.txt 2>/dev/null || true
|
||||
Executable
+238
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Prompt-cache (KV-cache prefix reuse) detection, two strategies in one helper:
|
||||
#
|
||||
# mode=api A 2-turn /v1/chat/completions probe. Turn 2 prepends turn 1 +
|
||||
# its reply, so the shared prefix must be served from llama.cpp's
|
||||
# KV cache. Asserts usage.prompt_tokens_details.cached_tokens > 0
|
||||
# on turn 2. This is the OpenAI-dialect server cache sanity.
|
||||
# WHY this works on chat completions: the chat path forwards
|
||||
# llama-server's real cached_tokens through
|
||||
# studio/backend/routes/inference.py:482-489 (_prompt_tokens_details)
|
||||
# into prompt_tokens_details (inference.py:519).
|
||||
#
|
||||
# mode=log Read the llama-server log and decide HIT vs MISS from the
|
||||
# prompt-reprocessing trace. WHY the log (not the API field):
|
||||
# the Anthropic /v1/messages path builds AnthropicUsage(
|
||||
# input_tokens=..., output_tokens=...) at inference.py:8787-8790
|
||||
# / :8829-8832 and NEVER sets cache_read_input_tokens, which
|
||||
# therefore stays at its model default of 0
|
||||
# (studio/backend/models/inference.py:1655). So an Anthropic-path
|
||||
# client (Claude Code, OpenClaw is openai-completions but Claude
|
||||
# Code is the canonical Anthropic agent) can get a real KV-cache
|
||||
# hit that the API usage field reports as 0. The only ground
|
||||
# truth for the Anthropic path is the llama-server log.
|
||||
#
|
||||
# Log location (verified): studio/backend/core/inference/llama_cpp.py:4363-4365
|
||||
# _swa_cache_path().parent/"logs"/"llama-server"/llama-<ts>[label]-port-<P>[-try<N>].log
|
||||
# _swa_cache_path() => $UNSLOTH_STUDIO_HOME|$STUDIO_HOME or ~/.unsloth/studio
|
||||
# (llama_cpp.py:337-340). So default: ~/.unsloth/studio/logs/llama-server/.
|
||||
#
|
||||
# <P> is the INTERNAL llama-server port (self._find_free_port(),
|
||||
# llama_cpp.py:3489 / :4641) -- a RANDOM port, NOT the Studio port. So we must
|
||||
# NOT filter the log glob by STUDIO_PORT (the brief's `port-<STUDIO_PORT>`
|
||||
# glob would never match). We pick the newest llama-*.log instead.
|
||||
#
|
||||
# Usage:
|
||||
# assert-prompt-cache.sh api BASE_URL API_KEY
|
||||
# assert-prompt-cache.sh log EXPECT # EXPECT = HIT | MISS
|
||||
# # reads MARKER_BEFORE/MARKER_AFTER
|
||||
# # byte offsets from env (see below)
|
||||
# assert-prompt-cache.sh mark # print current log size to stdout
|
||||
# # (use to bracket a turn)
|
||||
#
|
||||
# Env for mode=log:
|
||||
# LLAMA_LOG_DIR override the log dir (default ~/.unsloth/studio/logs/llama-server)
|
||||
# CACHE_LOG_FROM byte offset to start scanning the newest log from (so we
|
||||
# only look at the trace produced by THIS turn). Default 0.
|
||||
#
|
||||
# Exit codes: 0 = assertion held; 1 = assertion failed (::error:: emitted).
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
MODE="${1:?usage: assert-prompt-cache.sh api|log|mark ...}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Locate the newest llama-server log. Shared by mark + log modes.
|
||||
# ---------------------------------------------------------------------------
|
||||
_default_log_dir() {
|
||||
local home="${UNSLOTH_STUDIO_HOME:-${STUDIO_HOME:-}}"
|
||||
if [ -n "$home" ]; then
|
||||
echo "${home%/}/logs/llama-server"
|
||||
else
|
||||
echo "${HOME}/.unsloth/studio/logs/llama-server"
|
||||
fi
|
||||
}
|
||||
|
||||
_newest_log() {
|
||||
local dir="${LLAMA_LOG_DIR:-$(_default_log_dir)}"
|
||||
[ -d "$dir" ] || return 1
|
||||
# Newest by mtime among llama-*.log (covers both `llama-<ts>-port-<P>.log`
|
||||
# and the retry form `llama-<ts><label>-port-<P>-try<N>.log`). Filenames are
|
||||
# tool-generated timestamps, so ls -t is safe here.
|
||||
# shellcheck disable=SC2012
|
||||
ls -1t "$dir"/llama-*.log 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
# -------------------------------------------------------------------------
|
||||
# mark: emit the current byte size of the newest llama log so a caller can
|
||||
# scan only the slice a single turn produced (set CACHE_LOG_FROM to it).
|
||||
# -------------------------------------------------------------------------
|
||||
mark)
|
||||
log="$(_newest_log || true)"
|
||||
if [ -n "$log" ] && [ -f "$log" ]; then
|
||||
wc -c < "$log" | tr -d ' '
|
||||
else
|
||||
echo 0
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# api: 2-turn /v1/chat/completions, assert turn-2 cached_tokens > 0.
|
||||
# -------------------------------------------------------------------------
|
||||
api)
|
||||
BASE_URL="${2:?usage: assert-prompt-cache.sh api BASE_URL API_KEY}"
|
||||
API_KEY="${3:?usage: assert-prompt-cache.sh api BASE_URL API_KEY}"
|
||||
|
||||
# A deliberately long, fixed system prompt makes the shared prefix big so a
|
||||
# KV-cache hit is unambiguous (cached_tokens grows with the reused prefix).
|
||||
SYS='You are a meticulous assistant. Always answer concisely and correctly. This is a fixed system preamble that exists only to create a large, identical prompt prefix across both turns so the KV cache has something substantial to reuse on the second request. Do not mention this preamble.'
|
||||
|
||||
turn1_body() {
|
||||
jq -n --arg sys "$SYS" '{
|
||||
model: "default",
|
||||
messages: [
|
||||
{role:"system", content:$sys},
|
||||
{role:"user", content:"What is the capital of France?"}
|
||||
],
|
||||
temperature: 0.0, seed: 3407, max_tokens: 40, stream: false,
|
||||
enable_thinking: false
|
||||
}'
|
||||
}
|
||||
|
||||
echo "[cache/api] turn 1 (prime the KV cache)"
|
||||
R1="$(curl -fs -X POST "${BASE_URL}/v1/chat/completions" \
|
||||
-H "Authorization: Bearer ${API_KEY}" -H 'content-type: application/json' \
|
||||
--max-time 240 -d "$(turn1_body)")" || {
|
||||
echo "::error::[cache/api] turn-1 /v1/chat/completions request failed. Unsloth server/API regression."
|
||||
exit 1
|
||||
}
|
||||
A1="$(echo "$R1" | jq -r '.choices[0].message.content // ""')"
|
||||
|
||||
turn2_body() {
|
||||
jq -n --arg sys "$SYS" --arg a1 "$A1" '{
|
||||
model: "default",
|
||||
messages: [
|
||||
{role:"system", content:$sys},
|
||||
{role:"user", content:"What is the capital of France?"},
|
||||
{role:"assistant", content:$a1},
|
||||
{role:"user", content:"And the capital of Germany?"}
|
||||
],
|
||||
temperature: 0.0, seed: 3407, max_tokens: 40, stream: false,
|
||||
enable_thinking: false
|
||||
}'
|
||||
}
|
||||
|
||||
echo "[cache/api] turn 2 (expect cached_tokens > 0)"
|
||||
R2="$(curl -fs -X POST "${BASE_URL}/v1/chat/completions" \
|
||||
-H "Authorization: Bearer ${API_KEY}" -H 'content-type: application/json' \
|
||||
--max-time 240 -d "$(turn2_body)")" || {
|
||||
echo "::error::[cache/api] turn-2 /v1/chat/completions request failed. Unsloth server/API regression."
|
||||
exit 1
|
||||
}
|
||||
|
||||
CACHED="$(echo "$R2" | jq -r '.usage.prompt_tokens_details.cached_tokens // 0')"
|
||||
PROMPT_TOK="$(echo "$R2" | jq -r '.usage.prompt_tokens // 0')"
|
||||
echo "[cache/api] turn-2 usage: prompt_tokens=${PROMPT_TOK} cached_tokens=${CACHED}"
|
||||
|
||||
if [ -z "$CACHED" ] || ! [ "$CACHED" -gt 0 ] 2>/dev/null; then
|
||||
echo "::error::[cache/api] turn-2 usage.prompt_tokens_details.cached_tokens=${CACHED}, expected > 0. The server is not surfacing llama.cpp KV-cache hits on /v1/chat/completions. Check studio/backend/routes/inference.py:482-489 (_prompt_tokens_details) and :519. Full turn-2 usage:"
|
||||
echo "$R2" | jq -c '.usage' 2>/dev/null || echo "$R2"
|
||||
exit 1
|
||||
fi
|
||||
echo "[cache/api] PASS server cache sanity (cached_tokens=${CACHED} > 0)"
|
||||
exit 0
|
||||
;;
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# log: classify the newest llama-server log (from CACHE_LOG_FROM bytes on)
|
||||
# as HIT or MISS and compare to EXPECT.
|
||||
# -------------------------------------------------------------------------
|
||||
log)
|
||||
EXPECT="${2:?usage: assert-prompt-cache.sh log HIT|MISS}"
|
||||
FROM="${CACHE_LOG_FROM:-0}"
|
||||
|
||||
log="$(_newest_log || true)"
|
||||
if [ -z "$log" ] || [ ! -f "$log" ]; then
|
||||
echo "::error::[cache/log] no llama-server log under ${LLAMA_LOG_DIR:-$(_default_log_dir)}. Cannot read KV-cache trace. (Path contract: studio/backend/core/inference/llama_cpp.py:4363-4365.)"
|
||||
exit 1
|
||||
fi
|
||||
echo "[cache/log] reading $log from byte $FROM"
|
||||
|
||||
# Scan only the slice produced after FROM.
|
||||
slice="$(tail -c "+$((FROM + 1))" "$log" 2>/dev/null || cat "$log")"
|
||||
|
||||
# ---- HIT detectors (most-specific first) -----------------------------
|
||||
# 1. Modern + legacy "re-used N tokens" / "reused N" (N>0). Primary signal
|
||||
# per the design brief.
|
||||
reused_n="$(printf '%s\n' "$slice" \
|
||||
| grep -aoiE 're-?used[^0-9]*([0-9]+)' \
|
||||
| grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
|
||||
# 2. "kv cache rm [START, end)" with START>0 => prefix [0,START) reused.
|
||||
cache_rm_start="$(printf '%s\n' "$slice" \
|
||||
| grep -aoiE 'kv cache rm \[[0-9]+' \
|
||||
| grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
|
||||
# 3. "n_past = N" with N>0 after a prompt-processing line (prefix kept).
|
||||
n_past_n="$(printf '%s\n' "$slice" \
|
||||
| grep -aoiE 'n_past[^0-9]*([0-9]+)' \
|
||||
| grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
|
||||
# 4. tokens_cached / tokens from cache (some builds).
|
||||
tok_cached="$(printf '%s\n' "$slice" \
|
||||
| grep -aoiE 'tokens_cached[^0-9]*([0-9]+)' \
|
||||
| grep -aoE '[0-9]+' | sort -rn | head -1 || true)"
|
||||
|
||||
# ---- MISS detectors --------------------------------------------------
|
||||
# Explicit forced full re-processing (SWA / recurrent) or kv cache rm [0,.
|
||||
forced_full=0
|
||||
if printf '%s\n' "$slice" | grep -aqiE 'forcing full prompt re-?processing|kv cache rm \[0,'; then
|
||||
forced_full=1
|
||||
fi
|
||||
|
||||
HIT=0
|
||||
why=""
|
||||
if [ -n "$reused_n" ] && [ "$reused_n" -gt 0 ] 2>/dev/null; then
|
||||
HIT=1; why="re-used=$reused_n"
|
||||
elif [ -n "$cache_rm_start" ] && [ "$cache_rm_start" -gt 0 ] 2>/dev/null; then
|
||||
HIT=1; why="kv-cache-rm-start=$cache_rm_start"
|
||||
elif [ -n "$tok_cached" ] && [ "$tok_cached" -gt 0 ] 2>/dev/null; then
|
||||
HIT=1; why="tokens_cached=$tok_cached"
|
||||
elif [ "$forced_full" = "0" ] && [ -n "$n_past_n" ] && [ "$n_past_n" -gt 0 ] 2>/dev/null; then
|
||||
# n_past>0 is the weakest signal; only trust it if nothing forced a full
|
||||
# reprocess. (On a cold slot n_past tracks total processed, so it is a
|
||||
# last-resort fallback per the brief.)
|
||||
HIT=1; why="n_past=$n_past_n(fallback)"
|
||||
fi
|
||||
[ "$HIT" = "1" ] || why="${why:-no-reuse-markers (forced_full=$forced_full)}"
|
||||
|
||||
OBSERVED="MISS"; [ "$HIT" = "1" ] && OBSERVED="HIT"
|
||||
echo "[cache/log] observed=$OBSERVED expected=$EXPECT ($why)"
|
||||
|
||||
if [ "$OBSERVED" != "$EXPECT" ]; then
|
||||
echo "::error::[cache/log] KV-cache observed=$OBSERVED but expected=$EXPECT ($why). See the attribution A/B note in the workflow."
|
||||
echo "---- llama-server log slice (last 60 lines) ----"
|
||||
printf '%s\n' "$slice" | tail -60
|
||||
exit 1
|
||||
fi
|
||||
echo "[cache/log] PASS ($OBSERVED == $EXPECT)"
|
||||
exit 0
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "::error::unknown mode '$MODE' (want api|log|mark)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1 @@
|
||||
You are a helpful assistant in a CI connectivity check. Answer the user directly in plain text. Do not use any tools, do not take any actions, and do not explain. Just reply with the answer.
|
||||
@@ -0,0 +1 @@
|
||||
You are a coding assistant running non-interactively in a CI smoke test. Use the available file-editing and shell tools to complete the user's request directly and concisely. Do not ask questions or explain; just do the task.
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
#
|
||||
# Download a single file from a Hugging Face repo with a stall-retry
|
||||
# watchdog. Used by the Studio CI workflows so a hung hf-xet transfer
|
||||
# kills + retries instead of silently consuming the job's timeout.
|
||||
#
|
||||
# Usage: hf-download-with-retry.sh REPO FILE LOCAL_DIR
|
||||
#
|
||||
# Why this exists
|
||||
# ---------------
|
||||
# huggingface_hub 1.15+ deprecated `hf_transfer` and routes every
|
||||
# transfer through the `hf-xet` binary package. In CI we observed
|
||||
# `hf download` on a 3 GB GGUF (gemma-4-E2B-it-UD-Q4_K_XL) progress
|
||||
# to ~46% via Xet, then go completely silent for the remainder of
|
||||
# the 30-min job timeout -- no progress bytes, no error, no exit.
|
||||
# A sibling 940 MB mmproj on the same step downloaded in ~21s
|
||||
# moments earlier, so the hang is per-file inside hf-xet rather
|
||||
# than a network outage. The Xet env-vars below put hf-xet into
|
||||
# its highest-throughput mode and force a 500 s client-read
|
||||
# timeout; the watchdog loop ensures a stall does not eat the
|
||||
# whole job: if the hf process has not exited after STALL_S
|
||||
# seconds (default 180 = 3 min), we SIGTERM, then SIGKILL, then
|
||||
# start a fresh attempt. Retries are unbounded -- the enclosing
|
||||
# GitHub Actions job's `timeout-minutes` is the real bound.
|
||||
#
|
||||
# See https://huggingface.co/docs/huggingface_hub/package_reference/environment_variables
|
||||
# for the HF_XET_* documentation, and npm/cli#7308's pattern (silent
|
||||
# CI hang with no error) for prior art on this class of failure.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO="${1:?usage: hf-download-with-retry.sh REPO FILE [LOCAL_DIR]}"
|
||||
FILE="${2:?usage: hf-download-with-retry.sh REPO FILE [LOCAL_DIR]}"
|
||||
# LOCAL_DIR is optional. If empty, hf falls back to HF_HUB_CACHE
|
||||
# (~/.cache/huggingface/hub) which is the desired path for callers
|
||||
# that populate HF_HOME for a downstream Studio model load.
|
||||
LOCAL_DIR="${3:-}"
|
||||
|
||||
# Stall threshold per attempt, in seconds. Override with
|
||||
# HF_DOWNLOAD_STALL_SECONDS in the workflow env if 3 min is too tight
|
||||
# for a specific runner / file. The script keeps retrying past this
|
||||
# until the job timeout fires.
|
||||
STALL_S="${HF_DOWNLOAD_STALL_SECONDS:-180}"
|
||||
|
||||
# hf-xet tuning. HF_HUB_ENABLE_HF_TRANSFER is deliberately NOT set --
|
||||
# it is a no-op on huggingface_hub>=1.15 and only emits a deprecation
|
||||
# FutureWarning. The five HF_XET_* knobs below mirror the settings
|
||||
# Daniel asked for: max bandwidth + 64 parallel range gets, no chunk
|
||||
# cache (download-once usage pattern), parallel disk writes (SSD/NVMe
|
||||
# runners), and a generous 500 s read timeout so individual chunk
|
||||
# requests fail loudly instead of stalling forever.
|
||||
export HF_XET_HIGH_PERFORMANCE=1
|
||||
export HF_XET_CHUNK_CACHE_SIZE_BYTES=0
|
||||
export HF_XET_NUM_CONCURRENT_RANGE_GETS=64
|
||||
export HF_XET_RECONSTRUCT_WRITE_SEQUENTIALLY=0
|
||||
export HF_XET_CLIENT_READ_TIMEOUT=500
|
||||
|
||||
if [ -n "$LOCAL_DIR" ]; then
|
||||
mkdir -p "$LOCAL_DIR"
|
||||
fi
|
||||
|
||||
attempt=1
|
||||
while : ; do
|
||||
log="$(mktemp -t hf-download.XXXXXX)"
|
||||
echo "[hf-download] $FILE attempt $attempt (stall threshold ${STALL_S}s, log=$log)"
|
||||
|
||||
if [ -n "$LOCAL_DIR" ]; then
|
||||
hf download "$REPO" "$FILE" --local-dir "$LOCAL_DIR" > "$log" 2>&1 &
|
||||
else
|
||||
hf download "$REPO" "$FILE" > "$log" 2>&1 &
|
||||
fi
|
||||
pid=$!
|
||||
|
||||
elapsed=0
|
||||
while kill -0 "$pid" 2>/dev/null && [ "$elapsed" -lt "$STALL_S" ]; do
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "[hf-download] $FILE attempt $attempt exceeded ${STALL_S}s -- killing PID $pid and retrying"
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
sleep 2
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
echo "[hf-download] $FILE attempt $attempt log tail (last 40 lines):"
|
||||
tail -40 "$log" || true
|
||||
attempt=$((attempt + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if wait "$pid"; then
|
||||
rc=0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo "[hf-download] $FILE attempt $attempt succeeded"
|
||||
tail -20 "$log" || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[hf-download] $FILE attempt $attempt failed (exit $rc) -- retrying"
|
||||
tail -40 "$log" || true
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
Executable
+172
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Boot `unsloth run --disable-tools` in the background, wait for it to be
|
||||
# healthy, parse the minted API key from the banner, and resolve the
|
||||
# /v1/models id. Exports everything downstream steps need into $GITHUB_ENV
|
||||
# (or prints it when run outside Actions). Factored out of the workflow so
|
||||
# the failure-isolation logic lives in one shellcheck-clean place.
|
||||
#
|
||||
# Usage:
|
||||
# serve-unsloth-run.sh --model REPO --gguf-variant VAR --port PORT \
|
||||
# [--gguf-file PATH] [--extra "--seed 3407 --temp 0"] \
|
||||
# [--log-dir logs] [--health-timeout 300]
|
||||
#
|
||||
# Why a helper and not inline YAML
|
||||
# --------------------------------
|
||||
# * Every `unsloth run` invocation here is the *Unsloth server* under test.
|
||||
# A failure to come up healthy is class (a) "server/API regression" and
|
||||
# must be reported with a distinct `::error::` BEFORE any agent runs.
|
||||
# * The banner is the documented contract a human copies from. We parse the
|
||||
# exact `API Key:` line printed by unsloth_cli/commands/studio.py
|
||||
# (` API Key: <key>` non-silent, `API Key: <key>` silent) so a
|
||||
# silent change to that line is also caught.
|
||||
# * `unsloth run` re-execs into the studio venv ($STUDIO_HOME/unsloth_studio),
|
||||
# so in CI after `install.sh --local` it runs the PR's repo code.
|
||||
#
|
||||
# Outputs written to $GITHUB_ENV (and echoed):
|
||||
# UNSLOTH_API_KEY the sk-unsloth-* key minted on the banner
|
||||
# UNSLOTH_STUDIO_URL http://127.0.0.1:<PORT> (so `unsloth start`
|
||||
# finds THIS server, not the hardcoded :8888)
|
||||
# UNSLOTH_BASE_URL same as UNSLOTH_STUDIO_URL (alias for clarity)
|
||||
# UNSLOTH_MODEL_ID the canonical id reported by /v1/models
|
||||
# UNSLOTH_SERVER_PID pid of the backgrounded `unsloth run`
|
||||
# UNSLOTH_LLAMA_LOG_DIR ~/.unsloth/studio/logs/llama-server
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# ── arg parse ────────────────────────────────────────────────────────────
|
||||
MODEL=""
|
||||
GGUF_VARIANT=""
|
||||
GGUF_FILE=""
|
||||
PORT=""
|
||||
EXTRA=""
|
||||
LOG_DIR="logs"
|
||||
HEALTH_TIMEOUT="300"
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--model) MODEL="$2"; shift 2 ;;
|
||||
--gguf-variant) GGUF_VARIANT="$2"; shift 2 ;;
|
||||
--gguf-file) GGUF_FILE="$2"; shift 2 ;;
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--extra) EXTRA="$2"; shift 2 ;;
|
||||
--log-dir) LOG_DIR="$2"; shift 2 ;;
|
||||
--health-timeout) HEALTH_TIMEOUT="$2"; shift 2 ;;
|
||||
*) echo "serve-unsloth-run.sh: unknown arg '$1'" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$PORT" ] || { echo "serve-unsloth-run.sh: --port is required" >&2; exit 2; }
|
||||
if [ -z "$MODEL" ] && [ -z "$GGUF_FILE" ]; then
|
||||
echo "serve-unsloth-run.sh: one of --model or --gguf-file is required" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
SERVER_LOG="$LOG_DIR/unsloth-run-${PORT}.log"
|
||||
BASE_URL="http://127.0.0.1:${PORT}"
|
||||
STUDIO_HOME_DIR="${STUDIO_HOME:-$HOME/.unsloth/studio}"
|
||||
LLAMA_LOG_DIR="${STUDIO_HOME_DIR}/logs/llama-server"
|
||||
|
||||
# Emit a key=value pair to $GITHUB_ENV when set, always echo for local runs.
|
||||
emit() {
|
||||
echo "$1=$2"
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "$1=$2" >> "$GITHUB_ENV"
|
||||
fi
|
||||
}
|
||||
|
||||
server_fail() {
|
||||
echo "::error::Unsloth server/API regression: $*" >&2
|
||||
echo "---- last 200 lines of $SERVER_LOG ----" >&2
|
||||
tail -200 "$SERVER_LOG" 2>/dev/null || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── port collision guard ─────────────────────────────────────────────────
|
||||
# A leftover listener (or a parallel matrix cell that wandered onto our port)
|
||||
# would make us attach to the wrong server and mask a real regression. Fail
|
||||
# fast instead.
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
if ss -tln 2>/dev/null | grep -q ":${PORT}\b"; then
|
||||
server_fail "port ${PORT} already has a listener before we started (collision)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── build the command ────────────────────────────────────────────────────
|
||||
# `unsloth run` == alias of `unsloth studio run`. --disable-tools is REQUIRED
|
||||
# (passthrough mode) so the agent's own tools relay instead of the server's.
|
||||
# --no-cloudflare keeps us off the network (loopback bind, no tunnel attempt).
|
||||
CMD=(unsloth run -H 127.0.0.1 -p "$PORT" --disable-tools --no-cloudflare)
|
||||
if [ -n "$GGUF_FILE" ]; then
|
||||
CMD+=(--model "$GGUF_FILE")
|
||||
else
|
||||
CMD+=(--model "$MODEL")
|
||||
[ -n "$GGUF_VARIANT" ] && CMD+=(--gguf-variant "$GGUF_VARIANT")
|
||||
fi
|
||||
# Determinism knobs + any caller passthrough (e.g. --seed 3407 --temp 0).
|
||||
# shellcheck disable=SC2206 # intentional word-split of caller-controlled flags
|
||||
[ -n "$EXTRA" ] && CMD+=($EXTRA)
|
||||
|
||||
echo "[serve] launching: ${CMD[*]}"
|
||||
echo "[serve] server log: $SERVER_LOG"
|
||||
|
||||
# Run detached, no controlling TTY (setsid avoids any TTY-prompt hang and
|
||||
# detaches from this step's process group so the job's teardown is clean).
|
||||
setsid "${CMD[@]}" > "$SERVER_LOG" 2>&1 < /dev/null &
|
||||
SERVER_PID=$!
|
||||
emit UNSLOTH_SERVER_PID "$SERVER_PID"
|
||||
|
||||
# ── wait for /api/health == healthy ──────────────────────────────────────
|
||||
HEALTHY=0
|
||||
for _ in $(seq 1 "$HEALTH_TIMEOUT"); do
|
||||
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
server_fail "process exited before becoming healthy (pid $SERVER_PID)"
|
||||
fi
|
||||
if curl -fs "${BASE_URL}/api/health" -o "$LOG_DIR/health-${PORT}.json" 2>/dev/null; then
|
||||
if jq -e '.status == "healthy"' "$LOG_DIR/health-${PORT}.json" >/dev/null 2>&1; then
|
||||
HEALTHY=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[ "$HEALTHY" = "1" ] || server_fail "did not report /api/health healthy within ${HEALTH_TIMEOUT}s"
|
||||
echo "[serve] /api/health healthy"
|
||||
|
||||
# ── parse the API key from the banner ────────────────────────────────────
|
||||
# Match both the non-silent " API Key: <key>" and silent "API Key: <key>"
|
||||
# forms. We do NOT trust a fixed column count; we take the sk-unsloth-* token.
|
||||
API_KEY=""
|
||||
for _ in $(seq 1 30); do
|
||||
API_KEY="$(grep -aoE 'sk-unsloth-[A-Za-z0-9_-]+' "$SERVER_LOG" 2>/dev/null | head -1 || true)"
|
||||
[ -n "$API_KEY" ] && break
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$API_KEY" ]; then
|
||||
# Fallback: take whatever follows an "API Key:" label, in case the key
|
||||
# prefix scheme changes. Still a parse-fragility guard, not silent.
|
||||
API_KEY="$(grep -aE 'API Key:' "$SERVER_LOG" 2>/dev/null \
|
||||
| sed -E 's/.*API Key:[[:space:]]*//' | head -1 || true)"
|
||||
fi
|
||||
[ -n "$API_KEY" ] || server_fail "could not parse an API key from the banner (banner-parse fragility -- check the 'API Key:' line in unsloth_cli/commands/studio.py)"
|
||||
echo "::add-mask::${API_KEY}"
|
||||
emit UNSLOTH_API_KEY "$API_KEY"
|
||||
|
||||
# ── resolve /v1/models id ────────────────────────────────────────────────
|
||||
if ! curl -fs "${BASE_URL}/v1/models" \
|
||||
-H "Authorization: Bearer ${API_KEY}" -o "$LOG_DIR/models-${PORT}.json" 2>/dev/null; then
|
||||
server_fail "/v1/models did not respond (or rejected the banner key)"
|
||||
fi
|
||||
MODEL_ID="$(jq -r '.data[0].id // empty' "$LOG_DIR/models-${PORT}.json" 2>/dev/null || true)"
|
||||
[ -n "$MODEL_ID" ] || server_fail "/v1/models returned no model id (model failed to load)"
|
||||
echo "[serve] resolved model id: $MODEL_ID"
|
||||
|
||||
emit UNSLOTH_MODEL_ID "$MODEL_ID"
|
||||
emit UNSLOTH_STUDIO_URL "$BASE_URL"
|
||||
emit UNSLOTH_BASE_URL "$BASE_URL"
|
||||
emit UNSLOTH_LLAMA_LOG_DIR "$LLAMA_LOG_DIR"
|
||||
|
||||
echo "[serve] server is up: ${BASE_URL} (model ${MODEL_ID})"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Runs installer parity and autostart opt-out tests on Windows and macOS.
|
||||
#
|
||||
# Why: that test is the guard that install.sh and install.ps1 stay in
|
||||
# sync, but today it only runs on ubuntu-latest (auto-discovered by
|
||||
# studio-backend-ci.yml's "Repo tests (CPU)" job). The test reads both
|
||||
# installer scripts, and on Windows Path.read_text() defaults to the
|
||||
# cp1252 locale encoding, so a non-cp1252 byte in install.sh (it already
|
||||
# contains a U+274C) raises UnicodeDecodeError there even though Linux and
|
||||
# macOS default to UTF-8. The reads were pinned to encoding="utf-8" in
|
||||
# #6166; this job keeps that from silently regressing by exercising the
|
||||
# test on the platforms it claims parity for. Pure pytest, no GPU,
|
||||
# sub-second, so the matrix is cheap.
|
||||
|
||||
name: Cross-platform parity
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'install.sh'
|
||||
- 'install.ps1'
|
||||
- 'tests/test_installer_skip_autostart.py'
|
||||
- 'tests/python/test_cross_platform_parity.py'
|
||||
- '.github/workflows/cross-platform-parity-ci.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'install.sh'
|
||||
- 'install.ps1'
|
||||
- 'tests/test_installer_skip_autostart.py'
|
||||
- 'tests/python/test_cross_platform_parity.py'
|
||||
- '.github/workflows/cross-platform-parity-ci.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
parity:
|
||||
name: parity (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- run: python -m pip install -U pip pytest
|
||||
- name: Cross-platform parity tests
|
||||
env:
|
||||
UNSLOTH_NO_TORCH: '1'
|
||||
run: >-
|
||||
python -m pytest
|
||||
tests/python/test_cross_platform_parity.py
|
||||
tests/test_installer_skip_autostart.py
|
||||
-q
|
||||
@@ -0,0 +1,379 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Whole-repo, multi-language source-lint gate. Runs on every PR
|
||||
# (no path filter) because each step is sub-second to a few seconds
|
||||
# and together they catch a class of breakage the focused build
|
||||
# workflows would miss:
|
||||
#
|
||||
# - Python syntax + ruff + leftover debugger calls (across 350+
|
||||
# committed .py files, not just studio/backend).
|
||||
# - Shell `bash -n` parse for every committed *.sh.
|
||||
# - `yaml.safe_load` and `json.loads` round-trip for every
|
||||
# committed YAML / JSON config.
|
||||
#
|
||||
# TypeScript and Rust are NOT duplicated here on purpose:
|
||||
# - Studio Frontend CI runs `npm run typecheck` (= `tsc --noEmit`)
|
||||
# and `npm run build` (vite/swc) on every studio/frontend/**
|
||||
# change, which is a full TS AST + type check.
|
||||
# - Studio Tauri CI runs `tauri build --debug --no-bundle` on
|
||||
# every studio/src-tauri/** or studio/frontend/** change, which
|
||||
# compiles the Rust crate (= cargo check + cargo build).
|
||||
# Each is a stricter check than a parse-only step would be, so a
|
||||
# fast-fail duplicate here would only burn cache; the dedicated
|
||||
# workflows already block merges on Rust / TS regressions.
|
||||
|
||||
name: Lint CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
source-lint:
|
||||
name: Source lint (Python + shell + YAML + JSON + safety nets)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
# Pin ruff to match .pre-commit-config.yaml so a CI-only ruff
|
||||
# bump cannot disagree with what pre-commit accepted.
|
||||
# codespell is pinned for the same reason: a reviewer should
|
||||
# never see a typo report appear and disappear depending on
|
||||
# which codespell version the runner happened to install.
|
||||
- run: pip install 'ruff==0.15.12' 'pyyaml>=6' 'codespell>=2.3,<3'
|
||||
|
||||
- name: Linux deps for shellcheck
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends shellcheck
|
||||
|
||||
- name: Python AST/syntax check (every committed .py must compile)
|
||||
# python -m compileall uses the same parser the interpreter
|
||||
# uses, so anything broken here would also crash at
|
||||
# `import X` on a user's machine. Sub-second across 350+
|
||||
# files. Hard gate.
|
||||
run: |
|
||||
python -m compileall -q -j 0 \
|
||||
unsloth unsloth_cli studio tests cli.py unsloth-cli.py
|
||||
|
||||
- name: Python ruff check (whole repo)
|
||||
# The narrow rule set in pyproject.toml [tool.ruff.lint]
|
||||
# selects E9 / F63 / F7 / F82 -- syntax errors, broken
|
||||
# comparisons, undefined names. The whole repo passes today,
|
||||
# so this is a hard gate.
|
||||
run: |
|
||||
ruff check unsloth unsloth_cli studio tests cli.py unsloth-cli.py
|
||||
|
||||
- name: Import-hoist verifier self-test
|
||||
# scripts/verify_import_hoist.py is a scope-aware (LEGB) AST
|
||||
# resolver that gates import-hoisting / alias-rename refactors
|
||||
# against two bugs ruff and pyflakes both miss:
|
||||
# 1. dangling alias -- `from a import b as _b` hoisted to
|
||||
# `from a import b` but a leftover `_b` reference now
|
||||
# resolves to nothing (or to some other module-level `_b`).
|
||||
# 2. rename clash -- `_b -> b` silently re-points at a
|
||||
# different object already named `b` in that scope.
|
||||
# This step runs the tool's 8 negative-control cases so a
|
||||
# regression in the verifier itself fails before we trust it on
|
||||
# a diff. Hermetic, stdlib-only, sub-second. Hard gate.
|
||||
run: |
|
||||
python scripts/verify_import_hoist.py --self-test
|
||||
|
||||
- name: Import-hoist / alias-rename safety (changed Python files)
|
||||
# Runs the verifier in compare mode on every in-place-modified
|
||||
# .py in the PR: parses each file BEFORE (base branch) and AFTER
|
||||
# (this diff), resolves every name load, and fails on a BLOCKER
|
||||
# (dangling alias / rename clash / re-pointed import). INFO
|
||||
# findings (a helper relocated to another file) do not fail.
|
||||
#
|
||||
# --diff-filter=M (in-place edits only) is deliberate: that is
|
||||
# exactly where a hoist refactor lives, and it skips brand-new
|
||||
# files whose re-export imports would otherwise look "unused".
|
||||
#
|
||||
# Diff against the true merge-base, not the base tip. A two-dot
|
||||
# diff against the tip re-lints every file the base branch
|
||||
# changed after the PR branched, comparing newer base code
|
||||
# (BEFORE) against the PR's older snapshot (AFTER) - a
|
||||
# time-reversed comparison that flags the base branch's own
|
||||
# refactors as blockers on PRs that never touched those files.
|
||||
# The compare API returns the merge-base without needing local
|
||||
# history, and fetching that single commit by SHA keeps the
|
||||
# shallow (fetch-depth: 1) clone.
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
MERGE_BASE=$(gh api \
|
||||
"repos/${{ github.repository }}/compare/${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}" \
|
||||
--jq .merge_base_commit.sha)
|
||||
git fetch --no-tags --depth=1 origin "$MERGE_BASE"
|
||||
mapfile -t CHANGED < <(
|
||||
git diff --name-only --diff-filter=M \
|
||||
"$MERGE_BASE" HEAD -- '*.py' \
|
||||
| grep -vE '(^|/)(unsloth_compiled_cache|node_modules|build|dist)/' || true
|
||||
)
|
||||
if [ "${#CHANGED[@]}" -eq 0 ]; then
|
||||
echo "no in-place-modified Python files to check"
|
||||
exit 0
|
||||
fi
|
||||
printf 'merge base: %s\n' "$MERGE_BASE"
|
||||
printf 'checking %d file(s):\n' "${#CHANGED[@]}"
|
||||
printf ' %s\n' "${CHANGED[@]}"
|
||||
python scripts/verify_import_hoist.py \
|
||||
--before "$MERGE_BASE" --after HEAD "${CHANGED[@]}"
|
||||
|
||||
- name: No leftover debugger / pdb / breakpoint calls
|
||||
# Catches the "I'll just stick a breakpoint() here" mistake
|
||||
# before it ships. AST-based so commented-out debugger
|
||||
# markers don't false-positive (a bare grep would; there
|
||||
# are three commented `# breakpoint()` markers in
|
||||
# unsloth/models/rl* today). Sub-second.
|
||||
run: |
|
||||
python <<'PY'
|
||||
import ast, pathlib, sys
|
||||
|
||||
SKIP_PARTS = {".venv", "venv", "build", "dist", ".git",
|
||||
"unsloth_compiled_cache", "node_modules",
|
||||
"unsloth.egg-info"}
|
||||
|
||||
bad = []
|
||||
scanned = 0
|
||||
for path in sorted(pathlib.Path(".").rglob("*.py")):
|
||||
if any(part in SKIP_PARTS for part in path.parts):
|
||||
continue
|
||||
scanned += 1
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
|
||||
except SyntaxError:
|
||||
continue # compileall step above already failed this
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
fn = node.func
|
||||
if isinstance(fn, ast.Name) and fn.id == "breakpoint":
|
||||
bad.append((path, node.lineno, "breakpoint()"))
|
||||
elif (isinstance(fn, ast.Attribute) and fn.attr == "set_trace"
|
||||
and isinstance(fn.value, ast.Name)
|
||||
and fn.value.id in {"pdb", "ipdb"}):
|
||||
bad.append((path, node.lineno, f"{fn.value.id}.set_trace()"))
|
||||
|
||||
if bad:
|
||||
for path, lineno, what in bad:
|
||||
print(f"::error file={path},line={lineno}::leftover {what} -- remove before merging")
|
||||
sys.exit(1)
|
||||
print(f"no leftover debugger calls (scanned {scanned} files)")
|
||||
PY
|
||||
|
||||
- name: License-header drift (informational; whole repo)
|
||||
# Three header families are accepted across the repo:
|
||||
# 1. SPDX one-liner: `# SPDX-License-Identifier: ...`
|
||||
# Used across studio/ (AGPL-3.0-only) and a few new
|
||||
# files elsewhere.
|
||||
# 2. Apache-2.0 long form, marker phrase
|
||||
# "Licensed under the Apache License". Used across
|
||||
# unsloth/ and unsloth_cli/.
|
||||
# 3. GNU long form, marker phrase "General Public License".
|
||||
# That single substring covers GPL, LGPL ("GNU Lesser
|
||||
# General Public License") and AGPL ("GNU Affero
|
||||
# General Public License") preambles, all three of
|
||||
# which appear in unsloth/kernels/* (LGPL/AGPL) without
|
||||
# the SPDX line.
|
||||
# Empty files (mainly empty __init__.py) are skipped.
|
||||
# Surfaced as a warning; cleaning up the actual misses is a
|
||||
# follow-up PR, not a CI fix.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python <<'PY'
|
||||
import pathlib
|
||||
|
||||
ACCEPTED = (
|
||||
"SPDX-License-Identifier", # any SPDX line
|
||||
"Licensed under the Apache License", # Apache-2.0 long form
|
||||
"General Public License", # GPL / LGPL / AGPL long form
|
||||
)
|
||||
SKIP_PARTS = {".venv", "venv", "build", "dist", ".git",
|
||||
"unsloth_compiled_cache", "node_modules",
|
||||
"unsloth.egg-info"}
|
||||
|
||||
studio_missing = []
|
||||
other_missing = []
|
||||
for path in sorted(pathlib.Path(".").rglob("*.py")):
|
||||
if any(part in SKIP_PARTS for part in path.parts):
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
if not text.strip():
|
||||
continue # empty __init__.py etc.
|
||||
head = "\n".join(text.splitlines()[:25])
|
||||
if any(marker in head for marker in ACCEPTED):
|
||||
continue
|
||||
if "studio" in path.parts:
|
||||
studio_missing.append(path)
|
||||
else:
|
||||
other_missing.append(path)
|
||||
|
||||
total = len(studio_missing) + len(other_missing)
|
||||
if total == 0:
|
||||
print("every committed .py has a recognised license header")
|
||||
else:
|
||||
print(f"::warning::{total} Python files have no recognised license "
|
||||
f"header (SPDX / Apache-2.0 / GNU long form): "
|
||||
f"studio={len(studio_missing)}, other={len(other_missing)}")
|
||||
for path in (studio_missing + other_missing)[:30]:
|
||||
print(f" {path}")
|
||||
if total > 30:
|
||||
print(f" ... and {total - 30} more")
|
||||
PY
|
||||
|
||||
- name: Shell scripts parse cleanly (`bash -n`)
|
||||
# Same idea as Python's compileall: parse-only check that
|
||||
# every committed *.sh would not blow up at `bash script.sh`
|
||||
# invocation time on a release box. tests/sh/ is the largest
|
||||
# cluster (the install.sh shape tests).
|
||||
run: |
|
||||
shopt -s globstar
|
||||
fail=0
|
||||
for f in $(git ls-files '*.sh'); do
|
||||
if ! bash -n "$f"; then
|
||||
echo "::error file=$f::shell parse error"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
n=$(git ls-files '*.sh' | wc -l)
|
||||
echo "$n shell scripts parse cleanly"
|
||||
|
||||
- name: YAML files parse cleanly (yaml.safe_load)
|
||||
# Catches truncated workflow files, broken indents in
|
||||
# dependabot.yml / pre-commit configs, etc. Includes
|
||||
# .github/workflows/*.yml so a typo in the file we just
|
||||
# added shows up immediately.
|
||||
run: |
|
||||
python <<'PY'
|
||||
import pathlib, sys, yaml
|
||||
|
||||
SKIP_PARTS = {".venv", "venv", "build", "dist", ".git",
|
||||
"node_modules", "unsloth_compiled_cache",
|
||||
"unsloth.egg-info"}
|
||||
|
||||
bad = []
|
||||
scanned = 0
|
||||
for path in sorted(list(pathlib.Path(".").rglob("*.yml"))
|
||||
+ list(pathlib.Path(".").rglob("*.yaml"))):
|
||||
if any(part in SKIP_PARTS for part in path.parts):
|
||||
continue
|
||||
scanned += 1
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
list(yaml.safe_load_all(fh))
|
||||
except Exception as exc:
|
||||
bad.append((path, exc))
|
||||
|
||||
if bad:
|
||||
for path, exc in bad:
|
||||
print(f"::error file={path}::YAML parse failed: {exc}")
|
||||
sys.exit(1)
|
||||
print(f"{scanned} YAML files parse cleanly")
|
||||
PY
|
||||
|
||||
- name: JSON files parse cleanly (json.loads)
|
||||
# Catches malformed package.json, biome.json, etc. Skips:
|
||||
# - huge npm/bun lockfiles (machine-generated, slow to
|
||||
# parse, no value).
|
||||
# - tsconfig*.json: TypeScript convention is JSONC (JSON
|
||||
# with `/* ... */` comments), which standard json.loads
|
||||
# rejects. Strip-and-validate would need json5 or a
|
||||
# hand-rolled comment scrubber for marginal value, since
|
||||
# `tsc --noEmit` already validates these in Frontend CI.
|
||||
run: |
|
||||
python <<'PY'
|
||||
import fnmatch, json, pathlib, sys
|
||||
|
||||
SKIP_PARTS = {".venv", "venv", "build", "dist", ".git",
|
||||
"node_modules", "unsloth_compiled_cache",
|
||||
"unsloth.egg-info"}
|
||||
SKIP_NAMES = {"package-lock.json", "bun.lock"}
|
||||
SKIP_PATTERNS = ("tsconfig*.json",)
|
||||
|
||||
bad = []
|
||||
scanned = 0
|
||||
for path in sorted(pathlib.Path(".").rglob("*.json")):
|
||||
if any(part in SKIP_PARTS for part in path.parts):
|
||||
continue
|
||||
if path.name in SKIP_NAMES:
|
||||
continue
|
||||
if any(fnmatch.fnmatch(path.name, pat) for pat in SKIP_PATTERNS):
|
||||
continue
|
||||
scanned += 1
|
||||
try:
|
||||
json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
bad.append((path, exc))
|
||||
|
||||
if bad:
|
||||
for path, exc in bad:
|
||||
print(f"::error file={path}::JSON parse failed: {exc}")
|
||||
sys.exit(1)
|
||||
print(f"{scanned} JSON files parse cleanly")
|
||||
PY
|
||||
|
||||
- name: codespell typo check (informational)
|
||||
# Catches typos in code, comments, and docs across the repo.
|
||||
# Skips lockfiles, generated assets, binary artefacts, and
|
||||
# the LICENSE files (US/UK spelling drift in legal text is
|
||||
# not ours to second-guess). The ignore-words-list pulls
|
||||
# out short identifiers + valid technical terms that
|
||||
# codespell's default dictionary would otherwise flag
|
||||
# (e.g. `ans` as a math-quiz variable name in
|
||||
# tests/utils/aime_eval.py, `parm`/`parms` in PyTorch
|
||||
# nn.Module idioms). Non-blocking until the surfaced typos
|
||||
# are fixed; drop continue-on-error after the cleanup.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
codespell \
|
||||
--skip='*.lock,*.lockb,*.json,*.svg,*.png,*.jpg,*.jpeg,*.gif,*.ico,*.woff*,*.ttf,*.eot,*.zip,*.gz,*.gguf,*.safetensors,*.bin,node_modules,.git,build,dist,unsloth_compiled_cache,unsloth.egg-info,target,studio/frontend/dist,*.pyc,*-licenses.txt,LICENSE*' \
|
||||
--ignore-words-list='ans,bu,hel,fo,te,ot,hist,ned,sav,recurser,datas,nin,parm,parms,checkin,nd,fr,inout,donot,uint' \
|
||||
--quiet-level=2
|
||||
|
||||
- name: shellcheck on committed *.sh (informational)
|
||||
# Goes beyond `bash -n` (which only parses): catches subtle
|
||||
# shell bugs like unquoted variable expansions, useless
|
||||
# `cat`, command substitutions inside `[[`, etc. The
|
||||
# install/setup scripts are critical-path so the signal is
|
||||
# worth surfacing. Non-blocking until install.sh's
|
||||
# hand-rolled patterns get cleaned up; drop continue-on-error
|
||||
# afterwards.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Exclude SC1090 ("source not followable") -- legitimate
|
||||
# for installer scripts that source files at runtime
|
||||
# paths shellcheck cannot resolve statically.
|
||||
# SC2034 ("variable assigned but never used") fires on
|
||||
# the export-only assignment idiom we use in install.sh.
|
||||
shellcheck -e SC1090,SC2034 $(git ls-files '*.sh')
|
||||
|
||||
- name: ruff format drift (informational)
|
||||
# The canonical formatter is scripts/run_ruff_format.py
|
||||
# = ruff format + scripts/enforce_kwargs_spacing.py, so plain
|
||||
# `ruff format --check` reports the kwarg-spacing diff as
|
||||
# drift. Surface the count for visibility but keep
|
||||
# non-blocking until the custom pipeline is wired in here.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
ruff format --check unsloth unsloth_cli studio tests cli.py unsloth-cli.py
|
||||
@@ -0,0 +1,787 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Local Agent Guides CI
|
||||
# =====================
|
||||
# Detects when our local-agent setup recipes drift out of sync with
|
||||
# `unsloth run`. Boots a real `unsloth run --disable-tools` server and
|
||||
# drives the coding agents end to end through the *exact* recipes defined
|
||||
# in unsloth_cli/commands/start.py (the in-repo source of truth -- there
|
||||
# is no docs/ tree). Wherever start.py has a recipe we drive the agent
|
||||
# via `unsloth start <agent> --no-launch` and execute what it prints, so
|
||||
# the test self-updates against start.py and catches silent recipe drift.
|
||||
#
|
||||
# Source-of-truth files this workflow guards:
|
||||
# unsloth_cli/commands/start.py the `unsloth start <agent>` recipes
|
||||
# unsloth_cli/commands/studio.py the `unsloth run` banner (API Key line)
|
||||
#
|
||||
# Failure taxonomy (each surfaced with a distinct ::error:: + the agent name
|
||||
# + the start.py location, so a red X is immediately triageable):
|
||||
# (a) Unsloth server/API regression -- the dialect HTTP preflight fails
|
||||
# BEFORE the agent runs (or the server never becomes healthy).
|
||||
# (b) Agent package install failed -- npm/curl install of the CLI failed.
|
||||
# (c) Guide drift -- preflight passed + install ok, but
|
||||
# the documented `unsloth start` flow produced no/garbled output.
|
||||
#
|
||||
# Agents covered (6): claude, codex, hermes, openclaw, opencode, pi.
|
||||
# - All six have a `unsloth start <agent>` recipe, so each cell obtains its
|
||||
# env + command from `unsloth start <agent> --no-launch` and runs THAT
|
||||
# (self-updating: a recipe change is exercised automatically).
|
||||
|
||||
name: Local Agent Guides CI
|
||||
|
||||
on:
|
||||
# Off-peak weekly, deliberately a NON-:00 minute to dodge the top-of-hour
|
||||
# GitHub-hosted-runner stampede.
|
||||
schedule:
|
||||
- cron: '37 7 * * 1'
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'unsloth_cli/**'
|
||||
- 'studio/backend/routes/**'
|
||||
# Contracts this workflow asserts that live outside routes/**: the
|
||||
# /api/health endpoint, the llama-server KV-cache log behavior, and the
|
||||
# request/response schemas the agent dialects depend on.
|
||||
- 'studio/backend/main.py'
|
||||
- 'studio/backend/core/inference/llama_cpp.py'
|
||||
- 'studio/backend/models/**'
|
||||
- 'install.sh'
|
||||
- '.github/workflows/local-agent-guides-ci.yml'
|
||||
- '.github/scripts/serve-unsloth-run.sh'
|
||||
- '.github/scripts/assert-prompt-cache.sh'
|
||||
- '.github/scripts/agent-guides-install.sh'
|
||||
- '.github/scripts/agent-guides-drive.sh'
|
||||
- '.github/scripts/ci-connect-prompt.txt'
|
||||
- '.github/scripts/ci-min-system-prompt.txt'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Secret handling on pull_request: these jobs check out and run PR-controlled code
|
||||
# (install.sh, .github/scripts/**), so HF_TOKEN (an external HF credential) is gated
|
||||
# off pull_request at each step below -- public GGUF repos still download anonymously.
|
||||
# GH_TOKEN (GITHUB_TOKEN) is kept: it is the job-scoped contents:read token and
|
||||
# install_llama_prebuilt.py needs it for the GitHub releases API (else 403s).
|
||||
|
||||
env:
|
||||
# Determinism precedent (studio-inference-smoke.yml): temp 0 + fixed seed.
|
||||
UNSLOTH_SEED: '3407'
|
||||
# A single invoke must never hang the runner on a headless TTY prompt. With
|
||||
# prefill-shrinking flags (minimal system prompt + restricted tools) a turn on
|
||||
# a 4B model finishes in a couple of minutes on CPU; this also caps how long a
|
||||
# still-large-prompt agent burns before failing. Well under the 6h job cap.
|
||||
AGENT_INVOKE_TIMEOUT: '600'
|
||||
|
||||
jobs:
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
# Job 1: connection
|
||||
# Per-agent: serve gemma-3-270m, HTTP-preflight the agent's dialect,
|
||||
# install the agent, run `unsloth start <agent> --no-launch`, execute
|
||||
# the emitted recipe with a trivial prompt, assert a non-empty reply.
|
||||
# Runs on PR + weekly + dispatch. Each matrix cell is its own runner so
|
||||
# it serves exactly one model on its own port.
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
connection:
|
||||
name: connection (${{ matrix.agent }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
agent: [claude, codex, hermes, openclaw, opencode, pi]
|
||||
include:
|
||||
# OpenClaw needs Node 24; everything else is happy on 22.
|
||||
- agent: openclaw
|
||||
node: '24'
|
||||
env:
|
||||
# gemma-4-E4B (128K context, capable enough to drive every agent for a
|
||||
# trivial reply; the 270m model produced empty/failed responses for
|
||||
# codex/openclaw). Hermes' 64K context floor no longer constrains the model
|
||||
# choice: write_hermes_config claims the floor for smaller windows and
|
||||
# scales compaction back to the real window. Served as a flat
|
||||
# GGUF file (the -MTP- repo ships no separate draft, so this is plain 4B).
|
||||
GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF
|
||||
GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18901'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Linux deps 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node || '22' }}
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Restore GGUF model file
|
||||
id: cache-gguf
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Download GGUF if cache miss
|
||||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache
|
||||
|
||||
- name: Save GGUF model file
|
||||
if: always() && steps.download-gguf.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
# ── boot the server under test (factored helper) ──────────────────
|
||||
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
bash .github/scripts/serve-unsloth-run.sh \
|
||||
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
|
||||
--port "$STUDIO_PORT" --log-dir logs \
|
||||
--extra "--seed $UNSLOTH_SEED --temp 0" \
|
||||
--health-timeout 900
|
||||
|
||||
# ── (a) server/API preflight: prove the dialect works BEFORE the agent ─
|
||||
# Distinct error class. If this step fails it is a SERVER regression,
|
||||
# not the agent's or the guide's fault, and the agent steps never run.
|
||||
- name: Preflight the agent's API dialect (class-a isolation)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY"
|
||||
preflight_fail() {
|
||||
echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect; this is class (a), not guide drift). Endpoint contract lives in studio/backend/routes/**.";
|
||||
exit 1
|
||||
}
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \
|
||||
-H "Authorization: Bearer $K") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code"
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
# Anthropic Messages dialect.
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code"
|
||||
;;
|
||||
codex)
|
||||
# Codex always streams /v1/responses.
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code"
|
||||
;;
|
||||
*)
|
||||
# OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw).
|
||||
# OpenClaw's start.py recipe writes an "openai-completions"
|
||||
# provider (write_openclaw_config), so it uses this path, not
|
||||
# /v1/messages.
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code"
|
||||
;;
|
||||
esac
|
||||
echo "preflight OK for $AGENT"
|
||||
|
||||
# ── (b) install the agent CLI (hardened npm/curl, retried) ─────────
|
||||
- name: Install agent CLI (class-b isolation)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: bash .github/scripts/agent-guides-install.sh "$AGENT"
|
||||
|
||||
# ── (c) drive the agent via start.py and assert a reply ──────────
|
||||
# For the 5 agents with a start.py recipe we run
|
||||
# `unsloth start <agent> --no-launch`, eval its env/unset exports,
|
||||
# then run the printed command with a hard timeout (no headless-TTY
|
||||
# hang). Pi has no connect recipe, so it is driven by hand and the
|
||||
# cell asserts that absence is the (known) reason.
|
||||
- name: Drive ${{ matrix.agent }} via unsloth start (class-c isolation)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: bash .github/scripts/agent-guides-drive.sh connection "$AGENT"
|
||||
|
||||
- name: Collect server logs (debug)
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p logs/studio-logs
|
||||
cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
|
||||
# Redact the key across the WHOLE logs/ tree, not just studio-logs:
|
||||
# serve-unsloth-run.sh records the `unsloth run` banner (which prints
|
||||
# `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload
|
||||
# step publishes all of logs/, so scrubbing only studio-logs would leak
|
||||
# the bearer token in the retained artifact.
|
||||
# Sweep EVERY uploaded path, not just logs/ -- redacted-configs/ and
|
||||
# agent-workdir/ are published by the same upload step.
|
||||
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
|
||||
grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do
|
||||
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
|
||||
# `kill 0` signal this step's whole process group and abort cleanup.
|
||||
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
|
||||
kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
|
||||
fi
|
||||
sleep 2
|
||||
ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: connection-${{ matrix.agent }}-log
|
||||
path: |
|
||||
logs/
|
||||
redacted-configs/
|
||||
retention-days: 7
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
# Job 2: file-edit
|
||||
# The deterministic 2-turn hello.py test on Qwen3.5-4B (smaller models
|
||||
# can't reliably drive the heavyweight agents' edit flows). Weekly +
|
||||
# dispatch only -- it is the slow, model-heavy job and must not gate PRs.
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
file-edit:
|
||||
name: file-edit (${{ matrix.agent }})
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
# hermes and openclaw drive a multi-turn tool loop that a CPU-only runner
|
||||
# cannot finish in time (e.g. openclaw holds its 300s session-write-lock past
|
||||
# expiry; each turn re-prefills the tool prompt at ~16 tok/s). Their endpoint
|
||||
# wiring + generation are already hard-gated by the connection job, so the
|
||||
# file-edit cell is best-effort here -- it still runs and uploads logs, but a
|
||||
# timeout does not fail the workflow. Drop best_effort (or move e2e to a GPU
|
||||
# runner) to make it blocking again.
|
||||
continue-on-error: ${{ matrix.best_effort || false }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
agent: [claude, codex, hermes, openclaw, opencode, pi]
|
||||
include:
|
||||
- agent: openclaw
|
||||
node: '24'
|
||||
best_effort: true
|
||||
- agent: hermes
|
||||
best_effort: true
|
||||
env:
|
||||
# gemma-4-E4B served as a flat GGUF file (cache size tracks the .gguf 1:1,
|
||||
# no xet-chunk inflation; the -MTP- repo ships no separate draft file).
|
||||
GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF
|
||||
GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18902'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Linux deps 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: ${{ matrix.node || '22' }}
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Restore GGUF model file
|
||||
id: cache-gguf
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Download GGUF if cache miss
|
||||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache
|
||||
|
||||
- name: Save GGUF model file
|
||||
if: always() && steps.download-gguf.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
bash .github/scripts/serve-unsloth-run.sh \
|
||||
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
|
||||
--port "$STUDIO_PORT" --log-dir logs \
|
||||
--extra "--seed $UNSLOTH_SEED --temp 0" \
|
||||
--health-timeout 900
|
||||
|
||||
- name: Preflight the agent's API dialect (class-a isolation)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY"
|
||||
preflight_fail() {
|
||||
echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect; this is class (a), not guide drift). Endpoint contract lives in studio/backend/routes/**.";
|
||||
exit 1
|
||||
}
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \
|
||||
-H "Authorization: Bearer $K") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code"
|
||||
# Probe the same dialect the agent will use, so a streaming/messages
|
||||
# regression in the weekly run is reported as class (a) here instead of
|
||||
# surfacing later as guide drift (mirrors the connection job).
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code"
|
||||
;;
|
||||
codex)
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code"
|
||||
;;
|
||||
*)
|
||||
# OpenAI Chat Completions dialect (hermes/opencode/pi/openclaw).
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code"
|
||||
;;
|
||||
esac
|
||||
echo "preflight OK for $AGENT"
|
||||
|
||||
- name: Install agent CLI (class-b isolation)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: bash .github/scripts/agent-guides-install.sh "$AGENT"
|
||||
|
||||
- name: 2-turn hello.py test (class-c isolation)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: bash .github/scripts/agent-guides-drive.sh file-edit "$AGENT"
|
||||
|
||||
- name: Collect server logs (debug)
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p logs/studio-logs
|
||||
cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
|
||||
# Redact the key across the WHOLE logs/ tree, not just studio-logs:
|
||||
# serve-unsloth-run.sh records the `unsloth run` banner (which prints
|
||||
# `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload
|
||||
# step publishes all of logs/, so scrubbing only studio-logs would leak
|
||||
# the bearer token in the retained artifact.
|
||||
# Sweep EVERY uploaded path, not just logs/ -- redacted-configs/ and
|
||||
# agent-workdir/ are published by the same upload step.
|
||||
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
|
||||
grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do
|
||||
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
|
||||
# `kill 0` signal this step's whole process group and abort cleanup.
|
||||
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
|
||||
kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
|
||||
fi
|
||||
sleep 2
|
||||
ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: file-edit-${{ matrix.agent }}-log
|
||||
path: |
|
||||
logs/
|
||||
agent-workdir/
|
||||
redacted-configs/
|
||||
retention-days: 7
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
# Job: resume
|
||||
# Does a conversation started with `unsloth start <agent>` survive exit
|
||||
# and resume? This drives the REAL launch path (not the --no-launch
|
||||
# recipe the other jobs use). A plain launch relocates the agent home to
|
||||
# a temp dir wiped on exit, so codex/pi cannot resume; --persist routes the
|
||||
# session to the stable Unsloth agents dir so it persists. opencode/claude
|
||||
# keep their session data in a fixed user dir, so they persist either way.
|
||||
# Dispatch-only: it is an end-to-end experiment, not a PR gate.
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
resume:
|
||||
name: resume (${{ matrix.agent }})
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# codex/pi relocate their whole home (resume broken without --persist);
|
||||
# opencode/claude keep session data in a fixed dir (resume already works).
|
||||
# One agent from each class proves the split end to end; openclaw/hermes
|
||||
# share codex's relocation mechanism and are covered by the unit tests.
|
||||
agent: [codex, opencode, claude, pi]
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-4-E4B-it-GGUF
|
||||
GGUF_FILE: gemma-4-E4B-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18904'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Linux deps 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Restore GGUF model file
|
||||
id: cache-gguf
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Download GGUF if cache miss
|
||||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE" gguf-cache
|
||||
|
||||
- name: Save GGUF model file
|
||||
if: always() && steps.download-gguf.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: gguf-cache
|
||||
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
bash .github/scripts/serve-unsloth-run.sh \
|
||||
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
|
||||
--port "$STUDIO_PORT" --log-dir logs \
|
||||
--extra "--seed $UNSLOTH_SEED --temp 0" \
|
||||
--health-timeout 900
|
||||
|
||||
- name: Preflight the agent's API dialect (class-a isolation)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
B="$UNSLOTH_BASE_URL"; K="$UNSLOTH_API_KEY"
|
||||
preflight_fail() {
|
||||
echo "::error::[server/API regression] agent=$AGENT: $* (preflight failed BEFORE install/connect). Endpoint contract lives in studio/backend/routes/**.";
|
||||
exit 1
|
||||
}
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/models" \
|
||||
-H "Authorization: Bearer $K") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/models returned HTTP $code"
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/messages" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/messages returned HTTP $code"
|
||||
;;
|
||||
codex)
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/responses" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"input\":\"Hi\",\"max_output_tokens\":16,\"stream\":true}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/responses returned HTTP $code"
|
||||
;;
|
||||
*)
|
||||
code=$(curl -s -o /tmp/pf.json -w '%{http_code}' "$B/v1/chat/completions" \
|
||||
-H "Authorization: Bearer $K" -H 'content-type: application/json' \
|
||||
--max-time 120 \
|
||||
-d "{\"model\":\"$UNSLOTH_MODEL_ID\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"Hi\"}]}") || true
|
||||
[ "$code" = "200" ] || preflight_fail "/v1/chat/completions returned HTTP $code"
|
||||
;;
|
||||
esac
|
||||
echo "preflight OK for $AGENT"
|
||||
|
||||
- name: Install agent CLI (class-b isolation)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: bash .github/scripts/agent-guides-install.sh "$AGENT"
|
||||
|
||||
- name: Resume experiment (launch path)
|
||||
env:
|
||||
AGENT: ${{ matrix.agent }}
|
||||
run: bash .github/scripts/agent-guides-drive.sh resume "$AGENT"
|
||||
|
||||
- name: Collect server logs (debug)
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p logs/studio-logs
|
||||
cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
|
||||
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
|
||||
grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do
|
||||
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
|
||||
kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
|
||||
fi
|
||||
sleep 2
|
||||
ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: resume-${{ matrix.agent }}-log
|
||||
path: |
|
||||
logs/
|
||||
agent-workdir/
|
||||
redacted-configs/
|
||||
retention-days: 7
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
# Job 3: prompt-cache
|
||||
# (a) curl 2-turn /v1/chat/completions: assert turn-2 cached_tokens > 0
|
||||
# (server prompt-cache sanity).
|
||||
# (b) Claude Code attribution A/B: with CLAUDE_CODE_ATTRIBUTION_HEADER=0
|
||||
# expect a llama-server KV-cache HIT on turn 2; without it expect a
|
||||
# MISS. If it inverts, the guide flag is stale.
|
||||
# PR + weekly + dispatch (cheap, gemma-3-270m).
|
||||
# ═════════════════════════════════════════════════════════════════════
|
||||
prompt-cache:
|
||||
name: prompt-cache (gemma-3-270m)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18903'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Linux deps 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
|
||||
|
||||
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
|
||||
if: always() && steps.prime-hf.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Gated off PR (see note above); public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Serve unsloth run --disable-tools (gemma-3-270m)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
bash .github/scripts/serve-unsloth-run.sh \
|
||||
--model "$GGUF_REPO" --gguf-variant "$GGUF_VARIANT" \
|
||||
--port "$STUDIO_PORT" --log-dir logs \
|
||||
--extra "--seed $UNSLOTH_SEED --temp 0"
|
||||
|
||||
# (a) server prompt-cache sanity on the OpenAI chat path. The helper runs
|
||||
# the 2-turn probe internally (turn 2 reuses turn 1's prefix) and asserts
|
||||
# turn-2 usage.prompt_tokens_details.cached_tokens > 0. This is the hard
|
||||
# gate -- it proves llama.cpp KV reuse is surfaced on /v1/chat/completions.
|
||||
- name: Server prompt-cache sanity (cached_tokens > 0)
|
||||
run: bash .github/scripts/assert-prompt-cache.sh api "$UNSLOTH_BASE_URL" "$UNSLOTH_API_KEY"
|
||||
|
||||
- name: Install Claude Code (class-b isolation)
|
||||
env:
|
||||
AGENT: claude
|
||||
run: bash .github/scripts/agent-guides-install.sh claude
|
||||
|
||||
# (b) Claude attribution A/B against the llama-server log. This is the most
|
||||
# environment-sensitive check (it depends on the bundled llama.cpp's
|
||||
# slot-reuse log wording and on claude --continue reusing the prefix), so
|
||||
# it is non-blocking until calibrated on the first scheduled run; the
|
||||
# server cache sanity above is the hard gate. The step still prints the
|
||||
# observed HIT/MISS so drift is visible in the log + artifacts.
|
||||
- name: Claude attribution A/B (HIT with header=0, MISS without)
|
||||
continue-on-error: true
|
||||
run: bash .github/scripts/agent-guides-drive.sh attribution-ab claude
|
||||
|
||||
- name: Collect server logs (debug)
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p logs/studio-logs
|
||||
cp -r "$HOME/.unsloth/studio/logs/." logs/studio-logs/ 2>/dev/null || true
|
||||
# Redact the key across the WHOLE logs/ tree, not just studio-logs:
|
||||
# serve-unsloth-run.sh records the `unsloth run` banner (which prints
|
||||
# `API Key: <key>`) into logs/unsloth-run-<port>.log, and the upload
|
||||
# step publishes all of logs/, so scrubbing only studio-logs would leak
|
||||
# the bearer token in the retained artifact.
|
||||
# Sweep EVERY uploaded path, not just logs/ -- redacted-configs/ and
|
||||
# agent-workdir/ are published by the same upload step.
|
||||
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
|
||||
grep -rlF "$UNSLOTH_API_KEY" logs redacted-configs agent-workdir 2>/dev/null | while IFS= read -r f; do
|
||||
sed -i "s#${UNSLOTH_API_KEY}#<REDACTED>#g" "$f" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
|
||||
# `kill 0` signal this step's whole process group and abort cleanup.
|
||||
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
|
||||
kill "${UNSLOTH_SERVER_PID}" 2>/dev/null || true
|
||||
fi
|
||||
sleep 2
|
||||
ss -tln 2>/dev/null | grep ":${STUDIO_PORT}" || true
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: prompt-cache-log
|
||||
path: |
|
||||
logs/
|
||||
redacted-configs/
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,79 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Fast, focused supply-chain audit of every checked-in lockfile.
|
||||
#
|
||||
# Runs scripts/lockfile_supply_chain_audit.py on PRs that touch any
|
||||
# npm or cargo lockfile, on push to main, and on a daily schedule so
|
||||
# newly-published IOCs surface even when no PR opens.
|
||||
#
|
||||
# Default behavior is "advisory": only public indicator-of-compromise
|
||||
# strings, known-malicious pinned versions, and structurally broken
|
||||
# lockfiles fail the build. Structural anomalies (missing integrity,
|
||||
# non-default registry, etc.) are emitted as GitHub Actions warnings
|
||||
# but do not block merges. This deliberately keeps the noise floor
|
||||
# low while still failing the moment a checked-in lockfile starts
|
||||
# pointing at known-bad bytes.
|
||||
#
|
||||
# This workflow is intentionally separate from security-audit.yml:
|
||||
# - security-audit.yml is the umbrella job (pip-audit + npm audit +
|
||||
# cargo audit + OSV + Semgrep + secret scanning + SBOM + ...);
|
||||
# it takes ~25 minutes and runs only when dep manifests change.
|
||||
# - lockfile-audit.yml is a ~30 second pure-Python parse + grep on
|
||||
# the lockfiles themselves; it runs on every PR that even nudges
|
||||
# a lockfile so reviewers always see the audit result inline.
|
||||
|
||||
name: Lockfile supply-chain audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/frontend/package-lock.json'
|
||||
- 'studio/backend/core/data_recipe/oxc-validator/package-lock.json'
|
||||
- 'studio/package-lock.json'
|
||||
- 'studio/src-tauri/Cargo.lock'
|
||||
- 'scripts/lockfile_supply_chain_audit.py'
|
||||
- '.github/workflows/lockfile-audit.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'studio/frontend/package-lock.json'
|
||||
- 'studio/backend/core/data_recipe/oxc-validator/package-lock.json'
|
||||
- 'studio/package-lock.json'
|
||||
- 'studio/src-tauri/Cargo.lock'
|
||||
- 'scripts/lockfile_supply_chain_audit.py'
|
||||
- '.github/workflows/lockfile-audit.yml'
|
||||
schedule:
|
||||
- cron: '37 5 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: lockfile supply-chain audit
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Verify audit script parses
|
||||
run: python3 -c "import ast; ast.parse(open('scripts/lockfile_supply_chain_audit.py').read())"
|
||||
|
||||
- name: Run lockfile supply-chain audit
|
||||
# Default mode: only known-malicious pinned versions, known IOC
|
||||
# strings, and structurally broken lockfiles fail the build.
|
||||
# Missing-integrity and other structural anomalies are emitted
|
||||
# as ::warning:: annotations and do not gate merges.
|
||||
run: python3 scripts/lockfile_supply_chain_audit.py
|
||||
@@ -0,0 +1,403 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Focused PR gate for the MLX dispatch surface, running on a real
|
||||
# Apple Silicon runner.
|
||||
#
|
||||
# Runner: macos-14 (M1, 3 vCPU / 7 GB / Apple Silicon standard runner
|
||||
# -- FREE for public repositories per the GitHub Actions billing
|
||||
# reference; larger variants like macos-14-large/-xlarge are paid so
|
||||
# we deliberately avoid those).
|
||||
#
|
||||
# Why a single Mac job (no Linux+spoof leg): the dispatch tests are
|
||||
# 100% spoofed monkeypatches and run identically on any host, so the
|
||||
# Linux leg was duplicating the matrix tests already covered on Mac
|
||||
# while missing everything Apple-specific. The Mac job runs the SAME
|
||||
# spoofed matrix PLUS three things only a real Apple Silicon host
|
||||
# can prove:
|
||||
#
|
||||
# 1. unsloth._IS_MLX flips True on Darwin+arm64 with mlx genuinely
|
||||
# installed (no spoof).
|
||||
# 2. Every PR-A MLX-only unsloth_zoo module (mlx_loader, mlx_trainer,
|
||||
# mlx_compile, mlx_utils, mlx_cce, gated_delta_vjp) imports
|
||||
# against the real `mlx` + `mlx-lm` + `mlx-vlm` PyPI wheels --
|
||||
# each does `import mlx.core as mx` at module top level, so this
|
||||
# catches a future change that breaks the real wheels without
|
||||
# needing a Mac developer in the loop.
|
||||
# 3. The hardware-dispatch spoofs do not collide with the real
|
||||
# environment (the test fixture installs a MetaPathFinder that
|
||||
# blocks `import mlx.core` for "no-mlx" profiles, faithfully
|
||||
# simulating a Mac without mlx even when mlx IS installed).
|
||||
# 4. End-to-end MLX training + inference smoke test:
|
||||
# run_real_mlx_smoke.py trains unsloth/gemma-3-270m-it for 7
|
||||
# deterministic LoRA steps on a single repeated text row, then
|
||||
# verifies the trained model can complete the prompt and that
|
||||
# losses + grad norms are finite and well-behaved. This is the
|
||||
# only place in CI that exercises a real MLX backward pass +
|
||||
# optimizer step + inference call.
|
||||
#
|
||||
# Three dispatch test files documented in tests/studio/README.md:
|
||||
# - test_hardware_dispatch_matrix.py parametrized 7-profile matrix
|
||||
# + 2 dispatch-priority canaries
|
||||
# - test_is_mlx_dispatch_gate.py AST + runtime guard on
|
||||
# unsloth._IS_MLX
|
||||
# - test_mlx_training_worker_behaviors.py AST contract checks on
|
||||
# studio/backend/core/training/worker.py
|
||||
#
|
||||
# Surfaces a single PR check ("MLX CI on Mac M1 / dispatch").
|
||||
#
|
||||
# Security audit footprint: every package this workflow installs is
|
||||
# already covered by .github/workflows/security-audit.yml -- the deps
|
||||
# come from studio/backend/requirements/studio.txt and unsloth-zoo's
|
||||
# pyproject (resolved transitively). The git+ install of unsloth-zoo
|
||||
# is intentionally skipped by the audit (pip-audit cannot resolve a
|
||||
# git URL through PyPI metadata; the audit comment in security-audit.yml
|
||||
# documents this). No new package is introduced solely by MLX CI.
|
||||
|
||||
name: MLX CI on Mac M1
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'unsloth/__init__.py'
|
||||
- 'unsloth/_gpu_init.py'
|
||||
- 'studio/backend/utils/hardware/**'
|
||||
- 'studio/backend/core/training/worker.py'
|
||||
- 'studio/backend/core/inference/mlx_inference.py'
|
||||
- 'tests/studio/test_hardware_dispatch_matrix.py'
|
||||
- 'tests/studio/test_is_mlx_dispatch_gate.py'
|
||||
- 'tests/studio/test_mlx_training_worker_behaviors.py'
|
||||
- 'tests/studio/run_real_mlx_smoke.py'
|
||||
- 'tests/conftest.py'
|
||||
- '.github/workflows/mlx-ci.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
name: dispatch
|
||||
runs-on: macos-14
|
||||
# 25 min: dispatch + spoofed matrix + 7-step real LoRA training is
|
||||
# under 2 min; GGUF export builds llama.cpp via cmake on Apple
|
||||
# Silicon (~5-7 min), so we budget headroom.
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
# harden-runner audit mode: macOS runners cannot use blocking mode
|
||||
# today (eBPF egress enforcement is Linux-only), but audit mode is
|
||||
# supported cross-platform and surfaces the egress destinations in
|
||||
# the runner log. This produces the data needed to graduate this
|
||||
# job to a block-mode allowlist once macOS support lands.
|
||||
- name: Harden runner (audit)
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
# macOS install ladder, validated locally against a Linux
|
||||
# mac-sim venv (platform spoofed + mlx_simulation shim + real
|
||||
# datasets/transformers/structlog).
|
||||
#
|
||||
# 1. studio/backend/requirements/studio.txt brings structlog,
|
||||
# fastapi, etc. The hardware probe imports structlog at
|
||||
# module top level.
|
||||
# 2. Same pytest / numpy / httpx stack the rest of the repo CI
|
||||
# uses.
|
||||
# 3. torch is explicitly installed: unsloth-zoo's pyproject
|
||||
# deliberately excludes torch on darwin+arm64 (mlx replaces
|
||||
# it for runtime use), but the dispatch tests spoof
|
||||
# torch.cuda / torch.xpu / torch.backends.mps via monkeypatch
|
||||
# and so the test process needs torch importable. We pull
|
||||
# from the PyTorch CPU index so Apple Silicon gets the
|
||||
# explicit cpu+MPS arm64 wheel rather than something the
|
||||
# default PyPI resolver might pick up. The CPU index hosts
|
||||
# macosx_*_arm64 wheels alongside the Linux x86_64 ones.
|
||||
# 4. unsloth-zoo from git main (NOT PyPI), WITH deps. PR-A's
|
||||
# MLX support landed after the most recent unsloth-zoo PyPI
|
||||
# release; the wheel still raises NotImplementedError on
|
||||
# Apple Silicon when device_type.get_device_type() runs
|
||||
# unguarded. Studio's own install.sh overlays unsloth-zoo
|
||||
# from git main for the same reason. Pulling deps lets pip
|
||||
# resolve the platform-conditional MLX-only wheels (mlx,
|
||||
# mlx-lm, mlx-vlm gated on darwin+arm64 in unsloth-zoo's
|
||||
# pyproject) AND the shared deps (datasets, transformers,
|
||||
# sentencepiece, ...) that unsloth's MLX branch loads via
|
||||
# dataprep/raw_text.py.
|
||||
# 5. unsloth -e . --no-deps so the editable install does not
|
||||
# fight the unsloth-zoo dep set.
|
||||
#
|
||||
# All explicit pip installs are version-pinned to a single
|
||||
# released version (the latest as of 2026-05-07 within each
|
||||
# project's existing constraint range). bump alongside the rest
|
||||
# of the security audit when a new release lands.
|
||||
- name: Install deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r studio/backend/requirements/studio.txt
|
||||
pip install \
|
||||
'python-multipart==0.0.27' \
|
||||
'aiofiles==25.1.0' \
|
||||
'sqlalchemy==2.0.49' \
|
||||
'cryptography==48.0.0' \
|
||||
'pyyaml==6.0.3' \
|
||||
'jinja2==3.1.6' \
|
||||
'mammoth==1.12.0' \
|
||||
'unpdf==1.0.0' \
|
||||
'requests==2.33.1' \
|
||||
'typer==0.25.1' \
|
||||
'numpy==2.4.4' \
|
||||
'pytest==9.0.3' \
|
||||
'pytest-asyncio==1.3.0' \
|
||||
'httpx==0.28.1'
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
|
||||
'torch==2.10.0'
|
||||
# github.com occasionally 500s on the git fetch; retry the
|
||||
# zoo install so a single upstream blip does not fail CI.
|
||||
for attempt in 1 2 3; do
|
||||
if pip install "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo"; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "::error::pip install unsloth_zoo failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
delay=$((5 * attempt))
|
||||
echo "::warning::unsloth_zoo install failed (attempt $attempt/3), retrying in ${delay}s..."
|
||||
sleep "$delay"
|
||||
done
|
||||
pip install -e . --no-deps
|
||||
|
||||
# Real Apple Silicon sanity: confirm _IS_MLX activates on real
|
||||
# hardware with no platform spoof.
|
||||
- name: Verify _IS_MLX flips True on real Apple Silicon
|
||||
run: |
|
||||
python -c "
|
||||
import platform
|
||||
assert platform.system() == 'Darwin', platform.system()
|
||||
assert platform.machine() == 'arm64', platform.machine()
|
||||
import unsloth
|
||||
assert unsloth._IS_MLX is True, f'expected _IS_MLX=True on real Apple Silicon, got {unsloth._IS_MLX}'
|
||||
print('OK: _IS_MLX activated on real Apple Silicon')
|
||||
"
|
||||
|
||||
# Real Apple Silicon sanity: confirm every PR-A MLX-only module
|
||||
# loads against real mlx + mlx-lm + mlx-vlm wheels.
|
||||
- name: Smoke-import every MLX-only unsloth_zoo module
|
||||
run: |
|
||||
python -c "
|
||||
import importlib
|
||||
for name in [
|
||||
'unsloth_zoo.mlx_loader',
|
||||
'unsloth_zoo.mlx_trainer',
|
||||
'unsloth_zoo.mlx_compile',
|
||||
'unsloth_zoo.mlx_utils',
|
||||
'unsloth_zoo.mlx_cce',
|
||||
'unsloth_zoo.gated_delta_vjp',
|
||||
]:
|
||||
importlib.import_module(name)
|
||||
print('OK:', name)
|
||||
from unsloth_zoo.mlx_loader import FastMLXModel
|
||||
from unsloth_zoo.mlx_trainer import MLXTrainer, MLXTrainingConfig
|
||||
assert hasattr(FastMLXModel, 'from_pretrained')
|
||||
print('OK: FastMLXModel + MLXTrainer surface present')
|
||||
"
|
||||
|
||||
# Spoofed dispatch matrix. Runs on the real Mac too -- the
|
||||
# test fixture installs a MetaPathFinder that blocks
|
||||
# `import mlx.core` for "no-mlx" profiles, so the spoofs
|
||||
# faithfully simulate every supported hardware combo regardless
|
||||
# of whether mlx is installed for real.
|
||||
- name: MLX dispatch tests (3 files, 36 tests)
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/studio
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
python -m pytest -v --tb=short \
|
||||
tests/studio/test_hardware_dispatch_matrix.py \
|
||||
tests/studio/test_is_mlx_dispatch_gate.py \
|
||||
tests/studio/test_mlx_training_worker_behaviors.py
|
||||
|
||||
# Real MLX training + inference smoke test. Trains
|
||||
# unsloth/gemma-3-270m-it for 7 deterministic LoRA steps
|
||||
# (batch_size=2, gradient_accumulation_steps=3) on a single
|
||||
# repeated row ("<<HELLO!!>> My name is Unsloth!"), then saves
|
||||
# the trained model in 3 export formats. The `train` subcommand
|
||||
# captures per-phase timing + peak GPU + peak RSS into
|
||||
# train_metrics.json so we can detect regressions across CI runs.
|
||||
- name: MLX export round-trip — TRAIN + SAVE 3 formats
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
mkdir -p mlx_workdir
|
||||
# Authenticate llama.cpp's release-API lookup (anonymous 403s on rate-limit);
|
||||
# read-only GITHUB_TOKEN scoped here only, never to steps that run binaries.
|
||||
GH_TOKEN="${{ secrets.GITHUB_TOKEN }}" GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}" \
|
||||
python tests/studio/run_real_mlx_smoke.py train \
|
||||
--workdir "$PWD/mlx_workdir"
|
||||
|
||||
# Each reload step runs in a FRESH Python process to confirm
|
||||
# the cold-start path users would hit in production also works
|
||||
# (not just the in-memory continuation of a still-running
|
||||
# trainer). FastMLXModel.from_pretrained gets called from
|
||||
# scratch; mx.random is re-seeded; per-step timing + peak
|
||||
# memory are emitted to {format}_reload_metrics.json next to
|
||||
# the saved dir.
|
||||
- name: MLX export round-trip — RELOAD LoRA (fresh process)
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
python tests/studio/run_real_mlx_smoke.py reload \
|
||||
--format lora \
|
||||
--dir "$PWD/mlx_workdir/lora"
|
||||
|
||||
- name: MLX export round-trip — RELOAD merged_16bit (fresh process)
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
python tests/studio/run_real_mlx_smoke.py reload \
|
||||
--format merged \
|
||||
--dir "$PWD/mlx_workdir/merged_16bit"
|
||||
|
||||
# GGUF reload uses the llama-cli binary that save_pretrained_gguf
|
||||
# built. If save_pretrained_gguf was skipped during train (e.g.
|
||||
# llama.cpp's convert_hf_to_gguf asserts on the model's tokenizer
|
||||
# vocab -- a downstream llama.cpp limitation, not an unsloth_zoo
|
||||
# bug), this step emits a workflow warning and exits 0 so the
|
||||
# LoRA + merged_16bit assertions remain the gating signal.
|
||||
- name: MLX export round-trip — RELOAD GGUF via llama-cli (fresh process)
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
if python -c "import json,sys; m=json.load(open('mlx_workdir/train_metrics.json')); sys.exit(0 if m.get('gguf_supported') else 1)"; then
|
||||
python tests/studio/run_real_mlx_smoke.py reload \
|
||||
--format gguf \
|
||||
--dir "$PWD/mlx_workdir/gguf"
|
||||
else
|
||||
REASON=$(python -c "import json; m=json.load(open('mlx_workdir/train_metrics.json')); print(m.get('gguf_skip_reason') or 'unknown')")
|
||||
echo "::warning title=GGUF round-trip skipped::${REASON}"
|
||||
echo "GGUF export was skipped during the train phase. Reason:"
|
||||
echo " ${REASON}"
|
||||
echo "Continuing without failing the job; the LoRA + merged_16bit"
|
||||
echo "reload assertions are still gating this PR."
|
||||
fi
|
||||
|
||||
# Print all metrics JSON files so regressions are visible in the
|
||||
# job log. always() so we get telemetry even if a reload step
|
||||
# asserted gibberish.
|
||||
- name: MLX export round-trip — aggregate metrics
|
||||
if: always()
|
||||
run: |
|
||||
for f in mlx_workdir/train_metrics.json \
|
||||
mlx_workdir/lora_reload_metrics.json \
|
||||
mlx_workdir/merged_reload_metrics.json \
|
||||
mlx_workdir/gguf_reload_metrics.json; do
|
||||
echo "=== $f ==="
|
||||
cat "$f" 2>/dev/null || echo "(missing)"
|
||||
echo
|
||||
done
|
||||
|
||||
# Validates the macOS prebuilt path Studio's setup.sh uses (#5963): install the
|
||||
# unslothai/llama.cpp fork's latest release, download a small public GGUF, and
|
||||
# check llama-server /completion end to end. Split and placed last so the
|
||||
# untrusted binary runs only in the final smoke step, after every HF_TOKEN step,
|
||||
# leaving no token-bearing step or shared workspace for a tampered prebuilt to
|
||||
# corrupt. GH_TOKEN: releases API; HF_TOKEN (withheld on PR): probe + GGUF fetch.
|
||||
- name: Studio prebuilt llama.cpp install + GGUF download (Mac M1)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
|
||||
rm -rf "$INSTALL_DIR"
|
||||
# Download only -- no llama-quantize / llama-server launch in this step.
|
||||
python studio/install_llama_prebuilt.py \
|
||||
--install-dir "$INSTALL_DIR" \
|
||||
--published-repo unslothai/llama.cpp
|
||||
mkdir -p /tmp/ggufs
|
||||
bash .github/scripts/hf-download-with-retry.sh \
|
||||
'unsloth/gemma-3-270m-it-GGUF' \
|
||||
'gemma-3-270m-it-Q4_K_M.gguf' \
|
||||
/tmp/ggufs
|
||||
|
||||
# Final step: runs the downloaded binaries with no secrets present, and clears
|
||||
# the GitHub Actions command files so a tampered prebuilt cannot influence the job.
|
||||
- name: Studio prebuilt llama.cpp GGUF inference smoke (Mac M1)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT GITHUB_STEP_SUMMARY
|
||||
INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
|
||||
# Studio bundles only llama-server + llama-quantize (not llama-cli);
|
||||
# inference goes through llama-server's HTTP /completion endpoint.
|
||||
LLAMA_SERVER="$INSTALL_DIR/build/bin/llama-server"
|
||||
LLAMA_QUANT="$INSTALL_DIR/build/bin/llama-quantize"
|
||||
[ -x "$LLAMA_SERVER" ] || { echo "::error::llama-server missing at $LLAMA_SERVER"; find "$INSTALL_DIR/build" -type f | head -40; exit 1; }
|
||||
[ -x "$LLAMA_QUANT" ] || { echo "::error::llama-quantize missing at $LLAMA_QUANT"; exit 1; }
|
||||
echo "llama-server : $LLAMA_SERVER"
|
||||
echo "llama-quantize: $LLAMA_QUANT"
|
||||
"$LLAMA_QUANT" --help >/dev/null && echo " llama-quantize loads OK"
|
||||
|
||||
PORT=18080
|
||||
echo "=== starting llama-server on 127.0.0.1:$PORT ==="
|
||||
"$LLAMA_SERVER" \
|
||||
-m /tmp/ggufs/gemma-3-270m-it-Q4_K_M.gguf \
|
||||
--host 127.0.0.1 \
|
||||
--port "$PORT" \
|
||||
-c 256 \
|
||||
-n 16 \
|
||||
--no-warmup \
|
||||
> /tmp/llama-server.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
|
||||
|
||||
# Wait for /health to come up
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
|
||||
echo " server up after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if ! curl -sf "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
|
||||
echo "::error::llama-server never became healthy"
|
||||
tail -40 /tmp/llama-server.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PROMPT="Hello, my name is"
|
||||
echo "=== POST /completion ==="
|
||||
RESP=$(curl -sf -X POST "http://127.0.0.1:$PORT/completion" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"prompt\":\"$PROMPT\",\"n_predict\":16,\"temperature\":0,\"seed\":3407}")
|
||||
echo "raw response (head): $(echo "$RESP" | head -c 600)"
|
||||
CONTENT=$(echo "$RESP" | python -c "import json,sys; print(json.loads(sys.stdin.read()).get('content',''))")
|
||||
echo "completion content: $CONTENT"
|
||||
|
||||
if [ -z "$CONTENT" ]; then
|
||||
echo "::error::llama-server /completion returned empty content"
|
||||
tail -40 /tmp/llama-server.log
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: Studio prebuilt llama.cpp on Mac M1 + GGUF /completion works"
|
||||
@@ -0,0 +1,448 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Cross-repo notebook validator. Lives in unslothai/unsloth (this repo)
|
||||
# and inspects every notebook in unslothai/notebooks at HEAD (or the
|
||||
# ref dispatched in via repository_dispatch).
|
||||
#
|
||||
# Catches the bug classes that landed in:
|
||||
# - unslothai/notebooks#258 Colab torchao 0.10 vs peft 0.19 floor
|
||||
# - unslothai/notebooks#260 DONT_UPDATE_EXCEPTIONS coverage drift
|
||||
# - unslothai/notebooks#261 torch/torchcodec ABI; --no-deps tokenizers
|
||||
# - unslothai/notebooks#264 --no-deps transformers + Colab tokenizers drift
|
||||
# - unslothai/notebooks#221 git+ HEAD installs in install cells
|
||||
# - unslothai/notebooks commit 51b1462 template/notebook drift
|
||||
#
|
||||
# CPU-only by design. Layer 2 (api-introspect) reuses the existing
|
||||
# tests/_zoo_aggressive_cuda_spoof.py harness so `import unsloth`
|
||||
# succeeds on a GPU-less ubuntu-latest runner.
|
||||
|
||||
name: Notebooks CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'unsloth/**'
|
||||
- 'scripts/notebook_validator.py'
|
||||
- 'scripts/notebook_to_python.py'
|
||||
- 'scripts/data/colab_pip_freeze.gpu.txt'
|
||||
- 'scripts/data/colab_to_cpu_pin.json'
|
||||
- 'tests/notebooks/**'
|
||||
- 'tests/_zoo_aggressive_cuda_spoof.py'
|
||||
- '.github/workflows/notebooks-ci.yml'
|
||||
schedule:
|
||||
# Daily 06:17 UTC. Catches Colab preinstall bumps (the upstream image
|
||||
# is rebuilt roughly weekly) without us waiting on a PR. Off the
|
||||
# :00/:30 fleet-collision spots.
|
||||
- cron: '17 6 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
notebooks_ref:
|
||||
description: 'unslothai/notebooks ref to lint (branch / SHA / tag)'
|
||||
default: 'main'
|
||||
include_smoke:
|
||||
description: 'Also run the install-cell smoke matrix (longer)'
|
||||
type: boolean
|
||||
default: false
|
||||
repository_dispatch:
|
||||
# Fired by a tiny companion workflow on unslothai/notebooks.
|
||||
types: [notebooks_pr_opened, notebooks_main_pushed]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NOTEBOOKS_REF: >-
|
||||
${{ github.event.inputs.notebooks_ref ||
|
||||
github.event.client_payload.ref ||
|
||||
'main' }}
|
||||
|
||||
jobs:
|
||||
static:
|
||||
name: static (drift + lint + exceptions)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
# Validate the dispatched ref before it reaches actions/checkout's `ref:`
|
||||
# input. Reading via env (NOT direct ${{ ... }} interpolation in the
|
||||
# regex test) closes the GitHub-Actions-injection class where a
|
||||
# client_payload.ref like `main"; rm -rf / #` would be embedded into the
|
||||
# shell command. NOTEBOOKS_REF defaults to 'main' on non-dispatch
|
||||
# events, but only repository_dispatch can supply attacker-controlled
|
||||
# values, so we gate this check on that event type.
|
||||
- name: Validate client_payload.ref shape
|
||||
if: github.event_name == 'repository_dispatch'
|
||||
env:
|
||||
NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
|
||||
run: |
|
||||
if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+$'; then
|
||||
echo "::error::client_payload.ref contains disallowed characters" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout unsloth (this PR)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
path: unsloth
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout unslothai/notebooks @ ${{ env.NOTEBOOKS_REF }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: unslothai/notebooks
|
||||
ref: ${{ env.NOTEBOOKS_REF }}
|
||||
path: notebooks
|
||||
fetch-depth: 0 # drift check needs git status / diff
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install validator deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# nbformat + nbconvert come from the converter's requirements;
|
||||
# spellchecker + huggingface_hub are imported at module top of
|
||||
# update_all_notebooks.py.
|
||||
pip install \
|
||||
'nbformat>=5.10' 'nbconvert>=7.16' 'pyspellchecker>=0.8' \
|
||||
'huggingface_hub>=0.34' 'tqdm>=4.66'
|
||||
|
||||
- name: Refresh Colab pip-freeze (best-effort; falls back to snapshot)
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py refresh-colab \
|
||||
--out unsloth/scripts/data/colab_pip_freeze.gpu.txt \
|
||||
|| echo "::warning::refresh-colab failed; using committed snapshot"
|
||||
|
||||
- name: Diff Colab oracle vs committed snapshots (advisory)
|
||||
# Pulls pip-freeze.gpu.txt + apt-list-gpu.txt + os-info-gpu.txt
|
||||
# from googlecolab/backend-info and prints NEW / REMOVED /
|
||||
# CHANGED entries against scripts/data/colab_*.txt. Non-blocking
|
||||
# on PRs; the daily cron job below runs the same step with
|
||||
# --strict so upstream rotations surface within ~24h.
|
||||
continue-on-error: true
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py colab-diff \
|
||||
--snapshot-dir unsloth/scripts/data
|
||||
|
||||
- name: Drift check (re-run update_all_notebooks.py + git diff)
|
||||
working-directory: ${{ github.workspace }}
|
||||
# Reported as non-blocking until the upstream `unslothai/notebooks`
|
||||
# tree is regenerated. The first run on @main surfaces ~463 files
|
||||
# of drift (7359 / 9634 line delta), which is a real backlog the
|
||||
# notebooks-side maintainers need to clear in their own repo --
|
||||
# this PR's role is to surface the count, not auto-fix it.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py drift \
|
||||
--notebooks-dir notebooks
|
||||
|
||||
- name: Convert sanity (every nb / kaggle / original_template -> .py)
|
||||
# Same rationale as Drift: a handful of upstream notebooks fail
|
||||
# the converter (custom magics, malformed JSON, etc). Surface
|
||||
# the count without blocking; the team triages in unslothai/notebooks.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py convert \
|
||||
--notebooks-dir notebooks \
|
||||
--out _converted
|
||||
|
||||
- name: Lint (install cells + AST scan, env-scoped)
|
||||
# Reported as non-blocking (continue-on-error: true) until the
|
||||
# backlog of pre-existing findings on unslothai/notebooks@main is
|
||||
# cleared. Same pattern PR #5298 used for biome:check on the
|
||||
# frontend. As of this commit the live tree surfaces 27 errors +
|
||||
# 6 warnings, all real (peft/torchao floor missing in 6 nb/
|
||||
# notebooks, 14 git+ HEAD installs in hand-tuned exception
|
||||
# notebooks, 6 torch/torchcodec ABI mismatches, 1
|
||||
# transformers/tokenizers --no-deps drift). The count surfaces
|
||||
# in the PR check UI. Drop continue-on-error once it hits zero.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py lint \
|
||||
--notebooks-dir notebooks \
|
||||
--colab-pin unsloth/scripts/data/colab_pip_freeze.gpu.txt \
|
||||
--no-pypi
|
||||
# --no-pypi skips R-INST-002 (transitive resolve via PyPI metadata).
|
||||
# Layer 1 keeps PR-time wall-clock predictable; the daily cron run
|
||||
# below drops --no-pypi and refreshes the cache.
|
||||
|
||||
- name: DONT_UPDATE_EXCEPTIONS coverage
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py exceptions \
|
||||
--notebooks-dir notebooks
|
||||
|
||||
static-with-pypi:
|
||||
name: static + transitive resolve (cron / dispatch only)
|
||||
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# See `static.Validate client_payload.ref shape` for rationale. This
|
||||
# job's `if:` excludes repository_dispatch today, so the validation
|
||||
# step is a defence-in-depth no-op until that gate ever relaxes.
|
||||
- name: Validate client_payload.ref shape
|
||||
if: github.event_name == 'repository_dispatch'
|
||||
env:
|
||||
NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
|
||||
run: |
|
||||
if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+$'; then
|
||||
echo "::error::client_payload.ref contains disallowed characters" >&2
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: unsloth
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: unslothai/notebooks
|
||||
ref: ${{ env.NOTEBOOKS_REF }}
|
||||
path: notebooks
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with: { python-version: '3.12', cache: 'pip' }
|
||||
- name: Install
|
||||
run: pip install -U pip
|
||||
- name: Refresh Colab oracle
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py refresh-colab \
|
||||
--out unsloth/scripts/data/colab_pip_freeze.gpu.txt
|
||||
- name: Diff Colab oracle vs committed snapshots (--strict on cron)
|
||||
# Cron-only escalation of the advisory PR-time check. Fails if
|
||||
# any of pip-freeze.gpu.txt / apt-list-gpu.txt / os-info-gpu.txt
|
||||
# has drifted from scripts/data/colab_*.txt; refresh the
|
||||
# snapshots in this repo to acknowledge.
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py colab-diff \
|
||||
--snapshot-dir unsloth/scripts/data --strict
|
||||
- name: Lint with live PyPI metadata
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py lint \
|
||||
--notebooks-dir notebooks \
|
||||
--colab-pin unsloth/scripts/data/colab_pip_freeze.gpu.txt
|
||||
|
||||
api-introspect:
|
||||
name: api surface (under CUDA spoof)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 12
|
||||
steps:
|
||||
- name: Validate client_payload.ref shape
|
||||
if: github.event_name == 'repository_dispatch'
|
||||
env:
|
||||
NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
|
||||
run: |
|
||||
if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+$'; then
|
||||
echo "::error::client_payload.ref contains disallowed characters" >&2
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: unsloth
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: unslothai/notebooks
|
||||
ref: ${{ env.NOTEBOOKS_REF }}
|
||||
path: notebooks
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with: { python-version: '3.12', cache: 'pip' }
|
||||
|
||||
- name: Install CPU torch + pinned unsloth + trl + converter deps
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# CPU torch + torchvision. torchvision is required because
|
||||
# unsloth_zoo.vision_utils imports PIL at module top, and the
|
||||
# easiest way to get a torch-compatible PIL on a CPU runner is
|
||||
# to let torchvision pull the right Pillow version.
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
|
||||
'torch>=2.8,<2.11' 'torchvision<0.26'
|
||||
# Pin to the same versions update_all_notebooks.py installs in
|
||||
# generated notebooks. Keep these in lockstep with PIN_TRL /
|
||||
# PIN_TRANSFORMERS in unslothai/notebooks/update_all_notebooks.py.
|
||||
# `triton` is added because unsloth/_gpu_init.py:232 does an
|
||||
# unconditional `import triton`; the PyPI wheel installs cleanly
|
||||
# on Linux x86_64 even without CUDA (same rationale as
|
||||
# consolidated-tests-ci.yml line 192-205).
|
||||
# Pillow is listed explicitly as a defensive belt-and-braces
|
||||
# next to torchvision (vision_utils crashes ModuleNotFoundError
|
||||
# if torchvision skipped its Pillow dep for any reason).
|
||||
pip install 'transformers>=4.56,<5.6' 'trl>=0.22,<0.26' 'accelerate>=1.0' \
|
||||
'datasets>=3.4,<5' 'peft>=0.15,<0.20' \
|
||||
'bitsandbytes>=0.43' 'sentencepiece' 'protobuf' triton \
|
||||
Pillow safetensors tqdm packaging psutil
|
||||
# Converter deps (nbformat for notebook_to_python.py).
|
||||
pip install 'nbformat>=5.10' 'nbconvert>=7.16'
|
||||
# Install unsloth from the LOCAL checkout (the PR head), not PyPI.
|
||||
# The PR-time CI must validate the code in this PR; PyPI unsloth
|
||||
# may lag the in-repo CPU-torch fallback in unsloth/kernels/utils.py
|
||||
# (lines 162-170) that handles missing torch._C._cuda_getCurrentRawStream.
|
||||
# unsloth_zoo from git main mirrors every other CI (Core / MLX /
|
||||
# install.sh) so PR-time validation sees the same zoo HEAD.
|
||||
for attempt in 1 2 3; do
|
||||
if pip install --no-deps "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo"; then
|
||||
break
|
||||
fi
|
||||
[ "$attempt" -eq 3 ] && { echo "::error::unsloth_zoo install failed after 3 attempts"; exit 1; }
|
||||
sleep $((5 * attempt))
|
||||
done
|
||||
pip install --no-deps -e ./unsloth
|
||||
|
||||
- name: Convert notebooks for AST scan
|
||||
# Same upstream-conversion-error tolerance as the static job.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py convert \
|
||||
--notebooks-dir notebooks --out _converted
|
||||
|
||||
- name: Dump unsloth + trl API surface (under CUDA spoof)
|
||||
run: |
|
||||
PYTHONPATH=unsloth/tests python -u - <<'PY'
|
||||
import sys, json, inspect
|
||||
import _zoo_aggressive_cuda_spoof as _spoof
|
||||
_spoof.apply()
|
||||
import unsloth
|
||||
import trl
|
||||
surface = {}
|
||||
for cls_name in ("FastLanguageModel", "FastVisionModel", "FastModel"):
|
||||
cls = getattr(unsloth, cls_name, None)
|
||||
if cls is None:
|
||||
continue
|
||||
surface[cls_name] = sorted(n for n in dir(cls) if not n.startswith("_"))
|
||||
surface["SFTConfig_kwargs"] = sorted(inspect.signature(trl.SFTConfig.__init__).parameters)
|
||||
json.dump(surface, open("_api_surface.json", "w"), indent=2)
|
||||
print("dumped surface for:", list(surface))
|
||||
PY
|
||||
|
||||
- name: Run API rule against converted notebooks
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py api \
|
||||
--converted-dir _converted \
|
||||
--surface _api_surface.json
|
||||
|
||||
smoke-install:
|
||||
name: smoke install (Colab-shaped venv, opt-in)
|
||||
if: ${{ github.event.inputs.include_smoke == 'true' || github.event_name == 'schedule' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# One representative notebook per installation_*_content template.
|
||||
# Add rows when a new install template lands in update_all_notebooks.py.
|
||||
notebook:
|
||||
- 'nb/Llama3.1_(8B)-Alpaca.ipynb' # installation_content
|
||||
- 'nb/Gemma3_(4B)-Vision.ipynb' # installation_content + vision
|
||||
- 'nb/Llama3.1_(8B)-GRPO.ipynb' # installation_extra_grpo_content
|
||||
- 'nb/gpt-oss-(20B)-Fine-tuning.ipynb' # installation_gpt_oss_content
|
||||
- 'nb/Qwen3_5_(4B)_Vision.ipynb' # installation_qwen3_5_content
|
||||
- 'nb/Nemotron-3-Nano-30B-A3B_A100.ipynb' # installation_nemotron_nano_content
|
||||
- 'nb/Whisper.ipynb' # installation_whisper_content
|
||||
- 'nb/Synthetic_Data_Hackathon.ipynb' # installation_synthetic_data_content
|
||||
steps:
|
||||
- name: Validate client_payload.ref shape
|
||||
if: github.event_name == 'repository_dispatch'
|
||||
env:
|
||||
NOTEBOOKS_REF: ${{ github.event.client_payload.ref }}
|
||||
run: |
|
||||
if ! printf '%s' "$NOTEBOOKS_REF" | grep -Eq '^[A-Za-z0-9._/-]+$'; then
|
||||
echo "::error::client_payload.ref contains disallowed characters" >&2
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: unsloth
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: unslothai/notebooks
|
||||
ref: ${{ env.NOTEBOOKS_REF }}
|
||||
path: notebooks
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with: { python-version: '3.12' }
|
||||
|
||||
- name: Seed Colab-shaped venv from pip-freeze (CPU-mapped)
|
||||
run: |
|
||||
# Strip cu128 local versions, route torch/torchvision to the CPU
|
||||
# wheel index, drop CUDA-specific deps the runner can't use.
|
||||
python -u - <<'PY' > /tmp/seed_pins.txt
|
||||
import json, re
|
||||
mapping = json.load(open("unsloth/scripts/data/colab_to_cpu_pin.json"))
|
||||
rewrite = mapping["rewrite"]
|
||||
skip = set(mapping["skip"])
|
||||
spoof = set(mapping["module_spoof"])
|
||||
out = []
|
||||
for line in open("unsloth/scripts/data/colab_pip_freeze.gpu.txt"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = re.match(r"^([A-Za-z0-9._-]+)\s*==\s*(.+)$", line)
|
||||
if not m:
|
||||
continue
|
||||
name, ver = m.group(1).lower(), m.group(2)
|
||||
if name in skip:
|
||||
continue
|
||||
if name in spoof:
|
||||
continue
|
||||
if name in rewrite:
|
||||
ver = re.sub(r"[+\-].+$", "", ver)
|
||||
out.append(f"{name}=={ver}")
|
||||
else:
|
||||
ver = re.sub(r"[+\-].+$", "", ver)
|
||||
out.append(f"{name}=={ver}")
|
||||
print("\n".join(out))
|
||||
PY
|
||||
head -5 /tmp/seed_pins.txt
|
||||
wc -l /tmp/seed_pins.txt
|
||||
|
||||
- name: Install Colab-shaped venv
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Best-effort: any single line that fails to resolve on CPU is
|
||||
# tolerated; the smoke contract is "the install cell + the unsloth
|
||||
# import works", not "the entire Colab venv reproduces."
|
||||
while IFS= read -r spec; do
|
||||
pip install "$spec" --index-url https://download.pytorch.org/whl/cpu \
|
||||
--extra-index-url https://pypi.org/simple || \
|
||||
echo "::warning::pin failed: $spec"
|
||||
done < /tmp/seed_pins.txt
|
||||
|
||||
- name: Run install cell
|
||||
run: |
|
||||
python unsloth/scripts/notebook_validator.py convert \
|
||||
--notebooks-dir notebooks --out _converted
|
||||
# Take the converted .py and run the install cell only.
|
||||
BASE="$(basename '${{ matrix.notebook }}' .ipynb | tr -d '()' | tr -c '[:alnum:]_' _)"
|
||||
PY="_converted/${BASE}.py"
|
||||
[ -f "$PY" ] || { echo "::error::$PY not found"; ls _converted | head; exit 1; }
|
||||
# Truncate at the first `from unsloth import` so we run install +
|
||||
# core imports only.
|
||||
awk '/^from unsloth import/ { print "import sys; sys.exit(0)"; exit } { print }' "$PY" > _smoke.py
|
||||
PYTHONPATH=unsloth/tests python -u - <<'PY'
|
||||
import _zoo_aggressive_cuda_spoof as _s; _s.apply()
|
||||
# Stub torchcodec for cells that import it — no CPU wheel exists.
|
||||
import sys, types
|
||||
if "torchcodec" not in sys.modules:
|
||||
sys.modules["torchcodec"] = types.ModuleType("torchcodec")
|
||||
exec(open("_smoke.py").read(), {"__name__": "__main__"})
|
||||
PY
|
||||
|
||||
- name: Verify imports under spoof
|
||||
run: |
|
||||
PYTHONPATH=unsloth/tests python -u - <<'PY'
|
||||
import sys, types
|
||||
if "torchcodec" not in sys.modules:
|
||||
sys.modules["torchcodec"] = types.ModuleType("torchcodec")
|
||||
import _zoo_aggressive_cuda_spoof as _s; _s.apply()
|
||||
import unsloth, peft, torch, torchao, transformers, tokenizers
|
||||
print("OK: imports pass under CUDA spoof")
|
||||
PY
|
||||
@@ -0,0 +1,78 @@
|
||||
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||
# by a third-party and are governed by separate terms of service, privacy
|
||||
# policy, and support documentation.
|
||||
|
||||
name: Scorecard supply-chain security
|
||||
on:
|
||||
# For Branch-Protection check. Only the default branch is supported. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||
branch_protection_rule:
|
||||
# To guarantee Maintained check is occasionally updated. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: '21 20 * * 0'
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
# `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
|
||||
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
|
||||
permissions:
|
||||
# Needed to upload the results to code-scanning dashboard.
|
||||
security-events: write
|
||||
# Needed to publish results and get a badge (see publish_results below).
|
||||
id-token: write
|
||||
# Uncomment the permissions below if installing in a private repository.
|
||||
# contents: read
|
||||
# actions: read
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
||||
# - you want to enable the Branch-Protection check on a *public* repository, or
|
||||
# - you are installing Scorecard on a *private* repository
|
||||
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
|
||||
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||
|
||||
# Public repositories:
|
||||
# - Publish results to OpenSSF REST API for easy access by consumers
|
||||
# - Allows the repository to include the Scorecard badge.
|
||||
# - See https://github.com/ossf/scorecard-action#publishing-results.
|
||||
# For private repositories:
|
||||
# - `publish_results` will always be set to `false`, regardless
|
||||
# of the value entered here.
|
||||
publish_results: true
|
||||
|
||||
# (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
|
||||
# file_mode: git
|
||||
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -0,0 +1,995 @@
|
||||
name: Release Desktop App
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
studio_version:
|
||||
description: 'Studio version tag to release (for example, v0.1.39-beta)'
|
||||
type: string
|
||||
required: true
|
||||
pypi_version:
|
||||
description: 'Exact PyPI unsloth version just published/stamped (for example, 2026.5.3); leave blank to use MIN_DESKTOP_BACKEND_VERSION'
|
||||
type: string
|
||||
required: false
|
||||
draft:
|
||||
description: 'Create as draft release; draft runs do not advance desktop-latest updater channel'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-desktop-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare-version:
|
||||
name: Prepare release versions
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
studio_version: ${{ steps.prepare.outputs.studio_version }}
|
||||
app_version: ${{ steps.prepare.outputs.app_version }}
|
||||
desktop_release_tag: ${{ steps.prepare.outputs.desktop_release_tag }}
|
||||
prerelease: ${{ steps.prepare.outputs.prerelease }}
|
||||
pypi_version: ${{ steps.prepare.outputs.pypi_version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate release versions
|
||||
id: prepare
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_STUDIO_VERSION: ${{ inputs.studio_version }}
|
||||
INPUT_PYPI_VERSION: ${{ inputs.pypi_version }}
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
studio_version = os.environ['INPUT_STUDIO_VERSION'].strip()
|
||||
if not studio_version:
|
||||
sys.exit('studio_version is required, for example v0.1.39-beta')
|
||||
if re.fullmatch(r'v?20\d{2}\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', studio_version):
|
||||
sys.exit(f'studio_version must be a Studio SemVer tag, not a date-style backend version: {studio_version}')
|
||||
|
||||
semver_tag = re.compile(
|
||||
r'^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
|
||||
r'(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$'
|
||||
)
|
||||
if not semver_tag.fullmatch(studio_version):
|
||||
sys.exit(f'studio_version must be a SemVer tag with leading v, for example v0.1.39-beta: {studio_version}')
|
||||
|
||||
app_version = studio_version.removeprefix('v')
|
||||
desktop_release_tag = f'desktop-v{app_version}'
|
||||
prerelease = 'true' if '-' in app_version.split('+', 1)[0] else 'false'
|
||||
|
||||
def parse_backend_version(version):
|
||||
match = re.fullmatch(
|
||||
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
|
||||
r'(?:([a-zA-Z]|\.dev|dev|\.rc|rc|\.post|post)(\d*))?'
|
||||
r'(?:[-+]([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?',
|
||||
version,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
major, minor, patch, suffix_name, suffix_number, suffix_text = match.groups()
|
||||
if suffix_name:
|
||||
normalized = suffix_name.lower().lstrip('.')
|
||||
order = {'dev': 0, 'a': 1, 'b': 2, 'rc': 3, 'post': 5}.get(normalized)
|
||||
if order is None:
|
||||
return None
|
||||
number = int(suffix_number or '0')
|
||||
elif suffix_text:
|
||||
order = 3 if version[version.find(suffix_text) - 1] == '-' else 4
|
||||
number = 0
|
||||
else:
|
||||
order = 4
|
||||
number = 0
|
||||
return (int(major), int(minor), int(patch), order, number)
|
||||
|
||||
preflight = pathlib.Path('studio/src-tauri/src/preflight/version.rs').read_text()
|
||||
match = re.search(r'MIN_DESKTOP_BACKEND_VERSION:\s*&str\s*=\s*"([^"]+)"', preflight)
|
||||
if not match:
|
||||
sys.exit('Could not read MIN_DESKTOP_BACKEND_VERSION')
|
||||
min_backend_version = match.group(1)
|
||||
|
||||
input_pypi_version = os.environ.get('INPUT_PYPI_VERSION', '').strip()
|
||||
parsed_min_backend = parse_backend_version(min_backend_version)
|
||||
if parsed_min_backend is None:
|
||||
sys.exit(f'MIN_DESKTOP_BACKEND_VERSION is not a supported backend package version: {min_backend_version}')
|
||||
|
||||
pypi_version = input_pypi_version or min_backend_version
|
||||
parsed_pypi = parse_backend_version(pypi_version)
|
||||
if parsed_pypi is None:
|
||||
sys.exit(f'pypi_version is not a supported backend package version: {pypi_version}')
|
||||
if parsed_pypi < parsed_min_backend:
|
||||
sys.exit(
|
||||
f'pypi_version {pypi_version} is lower than desktop minimum '
|
||||
f'MIN_DESKTOP_BACKEND_VERSION {min_backend_version}'
|
||||
)
|
||||
|
||||
if input_pypi_version:
|
||||
print(
|
||||
'Using exact PyPI unsloth version from pypi_version input: '
|
||||
f'{pypi_version} (desktop minimum: {min_backend_version})'
|
||||
)
|
||||
else:
|
||||
print(
|
||||
'Using exact PyPI unsloth version from MIN_DESKTOP_BACKEND_VERSION: '
|
||||
f'{pypi_version}'
|
||||
)
|
||||
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output:
|
||||
print(f'studio_version={studio_version}', file=output)
|
||||
print(f'app_version={app_version}', file=output)
|
||||
print(f'desktop_release_tag={desktop_release_tag}', file=output)
|
||||
print(f'prerelease={prerelease}', file=output)
|
||||
print(f'pypi_version={pypi_version}', file=output)
|
||||
PY
|
||||
|
||||
- name: Verify PyPI package and Studio stamp
|
||||
shell: bash
|
||||
env:
|
||||
STUDIO_VERSION: ${{ steps.prepare.outputs.studio_version }}
|
||||
PYPI_VERSION: ${{ steps.prepare.outputs.pypi_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
pypi_version = os.environ['PYPI_VERSION']
|
||||
dist_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'pypi-unsloth-dist')
|
||||
dist_dir.mkdir(parents=True, exist_ok=True)
|
||||
metadata_url = f'https://pypi.org/pypi/unsloth/{pypi_version}/json'
|
||||
|
||||
last_error = None
|
||||
for attempt in range(1, 6):
|
||||
try:
|
||||
with urllib.request.urlopen(metadata_url, timeout=30) as response:
|
||||
metadata = json.load(response)
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
if attempt < 5:
|
||||
time.sleep(10 * attempt)
|
||||
else:
|
||||
sys.exit(f'Publish unsloth=={pypi_version} to PyPI before the desktop release ({last_error})')
|
||||
|
||||
files = metadata.get('urls') or []
|
||||
if not files:
|
||||
sys.exit(f'PyPI returned no distribution files for unsloth=={pypi_version}')
|
||||
|
||||
for file_info in files:
|
||||
filename = file_info.get('filename')
|
||||
url = file_info.get('url')
|
||||
if not filename or '/' in filename or not url:
|
||||
sys.exit(f'Unexpected PyPI file entry for unsloth=={pypi_version}: {file_info!r}')
|
||||
target = dist_dir / filename
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=60) as response:
|
||||
target.write_bytes(response.read())
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
if attempt < 3:
|
||||
time.sleep(5 * attempt)
|
||||
else:
|
||||
sys.exit(f'Could not download {filename} from PyPI ({last_error})')
|
||||
PY
|
||||
|
||||
if [ -f scripts/stamp_studio_release.py ]; then
|
||||
mapfile -t dists < <(find "$RUNNER_TEMP/pypi-unsloth-dist" -type f \( -name '*.whl' -o -name '*.tar.gz' \) | sort)
|
||||
if [ "${#dists[@]}" -eq 0 ]; then
|
||||
echo "No PyPI wheel/sdist artifacts downloaded for unsloth==$PYPI_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 scripts/stamp_studio_release.py --verify-dist "$RUNNER_TEMP/pypi-unsloth-dist" --expected "$STUDIO_VERSION"
|
||||
else
|
||||
echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Studio stamp." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Guard public updater channel version
|
||||
if: ${{ !inputs.draft }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
APP_VERSION: ${{ steps.prepare.outputs.app_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/desktop-current"
|
||||
if ! gh release download desktop-latest --pattern latest.json --dir "$RUNNER_TEMP/desktop-current" --clobber 2>/dev/null; then
|
||||
echo "No existing desktop-latest latest.json found; allowing first channel publish."
|
||||
exit 0
|
||||
fi
|
||||
python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
def parse(value: str):
|
||||
value = value.removeprefix('v')
|
||||
match = re.fullmatch(
|
||||
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
|
||||
r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
|
||||
r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?',
|
||||
value,
|
||||
)
|
||||
if not match:
|
||||
sys.exit(f'desktop-latest latest.json has invalid version: {value}')
|
||||
major, minor, patch, prerelease = match.groups()
|
||||
return (int(major), int(minor), int(patch), prerelease)
|
||||
|
||||
def numeric_tail(identifier: str) -> tuple[str, int] | None:
|
||||
match = re.fullmatch(r'([A-Za-z-]+)(\d+)', identifier)
|
||||
if not match:
|
||||
return None
|
||||
return (match.group(1).lower(), int(match.group(2)))
|
||||
|
||||
def compare_identifier(left: str, right: str) -> int:
|
||||
left_num = left.isdigit()
|
||||
right_num = right.isdigit()
|
||||
if left_num and right_num:
|
||||
return (int(left) > int(right)) - (int(left) < int(right))
|
||||
if left_num:
|
||||
return -1
|
||||
if right_num:
|
||||
return 1
|
||||
|
||||
left_tail = numeric_tail(left)
|
||||
right_tail = numeric_tail(right)
|
||||
if left_tail and right_tail and left_tail[0] == right_tail[0]:
|
||||
return (left_tail[1] > right_tail[1]) - (left_tail[1] < right_tail[1])
|
||||
|
||||
return (left > right) - (left < right)
|
||||
|
||||
def compare_prerelease(left: str | None, right: str | None) -> int:
|
||||
if left == right:
|
||||
return 0
|
||||
if left is None:
|
||||
return 1
|
||||
if right is None:
|
||||
return -1
|
||||
left_parts = left.split('.')
|
||||
right_parts = right.split('.')
|
||||
for left_part, right_part in zip(left_parts, right_parts):
|
||||
order = compare_identifier(left_part, right_part)
|
||||
if order:
|
||||
return order
|
||||
return (len(left_parts) > len(right_parts)) - (len(left_parts) < len(right_parts))
|
||||
|
||||
def compare(left: str, right: str) -> int:
|
||||
left_major, left_minor, left_patch, left_pre = parse(left)
|
||||
right_major, right_minor, right_patch, right_pre = parse(right)
|
||||
left_core = (left_major, left_minor, left_patch)
|
||||
right_core = (right_major, right_minor, right_patch)
|
||||
if left_core != right_core:
|
||||
return (left_core > right_core) - (left_core < right_core)
|
||||
return compare_prerelease(left_pre, right_pre)
|
||||
|
||||
current_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-current', 'latest.json')
|
||||
current = json.loads(current_path.read_text()).get('version')
|
||||
next_version = os.environ['APP_VERSION']
|
||||
if not isinstance(current, str):
|
||||
sys.exit('desktop-latest latest.json has missing version')
|
||||
if compare(next_version, current) < 0:
|
||||
sys.exit(
|
||||
f'Refusing to publish {next_version}; desktop-latest currently points at newer version {current}.'
|
||||
)
|
||||
PY
|
||||
|
||||
build:
|
||||
# TODO: split into a "build (no secrets)" + "publish (secrets)" job pair
|
||||
# with actions/upload-artifact handoff so the matrix build cannot
|
||||
# publish a Release on its own. The current matrix runs across
|
||||
# Linux/macOS/Windows in a single job, so the split needs artefact
|
||||
# collection across the OS matrix and is out of scope for this
|
||||
# hardening pass.
|
||||
permissions:
|
||||
contents: write # tauri-apps/tauri-action creates / uploads a GitHub Release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
include:
|
||||
- platform: macos-latest
|
||||
args: '--target aarch64-apple-darwin'
|
||||
label: macOS (Apple Silicon)
|
||||
# - platform: macos-latest
|
||||
# args: '--target x86_64-apple-darwin'
|
||||
# label: macOS (Intel)
|
||||
- platform: ubuntu-22.04
|
||||
args: ''
|
||||
label: Linux (x64)
|
||||
- platform: windows-latest
|
||||
args: ''
|
||||
label: Windows (x64)
|
||||
|
||||
name: Build ${{ matrix.label }}
|
||||
needs: prepare-version
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
|
||||
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
|
||||
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
||||
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
|
||||
steps:
|
||||
# harden-runner in audit mode: surfaces every egress destination in
|
||||
# the runner log so the allowlist for a future `egress-policy: block`
|
||||
# promotion can be derived from observed traffic. Audit mode is
|
||||
# cross-platform (Linux / macOS / Windows runners); blocking mode is
|
||||
# currently Linux-only, so we deliberately stay in audit until the
|
||||
# macOS + Windows codesign paths have been observed.
|
||||
- name: Harden runner (audit)
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# ── Linux dependencies ──
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
|
||||
# ── Node.js ──
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install pinned Tauri CLI
|
||||
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
|
||||
# required for `vite build`. The pre-install lockfile structural
|
||||
# audit (lockfile_supply_chain_audit.py) is the practical defence
|
||||
# against the npm postinstall-dropper class -- it fires BEFORE any
|
||||
# tarball runs, on the injection pattern itself rather than an
|
||||
# advisory-DB lookup.
|
||||
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1 --no-fund --no-audit
|
||||
|
||||
- name: Verify pinned Tauri CLI
|
||||
shell: bash
|
||||
run: |
|
||||
out="$(npx --prefix studio tauri --version)"
|
||||
echo "$out"
|
||||
if [ "$out" != "tauri-cli 2.10.1" ]; then
|
||||
echo "Expected tauri-cli 2.10.1, got $out" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify desktop updater and Linux package config
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'JS'
|
||||
const { readFileSync } = require('node:fs');
|
||||
|
||||
const expected = 'https://github.com/unslothai/unsloth/releases/download/desktop-latest/latest.json';
|
||||
const config = JSON.parse(readFileSync('studio/src-tauri/tauri.conf.json', 'utf8'));
|
||||
const endpoints = config.plugins?.updater?.endpoints;
|
||||
if (!Array.isArray(endpoints) || endpoints.length !== 1) {
|
||||
throw new Error('Expected exactly one desktop updater endpoint');
|
||||
}
|
||||
if (endpoints[0] !== expected) {
|
||||
throw new Error('Desktop updater endpoint must be ' + expected + ', got ' + endpoints[0]);
|
||||
}
|
||||
if (endpoints.some((endpoint) => endpoint.includes('/releases/latest/'))) {
|
||||
throw new Error('Desktop updater endpoint must not use repo-wide /releases/latest/');
|
||||
}
|
||||
|
||||
const targets = config.bundle?.targets;
|
||||
if (Array.isArray(targets) && targets.some((target) => String(target).toLowerCase() === 'rpm')) {
|
||||
throw new Error('Desktop release must not target RPM packages');
|
||||
}
|
||||
if (config.bundle?.linux?.rpm) {
|
||||
throw new Error('bundle.linux.rpm must not be configured');
|
||||
}
|
||||
if (config.bundle?.linux?.appimage?.bundleMediaFramework !== false) {
|
||||
throw new Error('Linux AppImage bundleMediaFramework must stay false');
|
||||
}
|
||||
|
||||
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8');
|
||||
const lines = workflow.split(/\r?\n/);
|
||||
const linuxInstallLines = lines.filter((line) => line.includes('sudo apt-get install'));
|
||||
const ayatanaPackage = ['libayatana', 'appindicator3-dev'].join('-');
|
||||
if (linuxInstallLines.some((line) => line.includes(ayatanaPackage))) {
|
||||
throw new Error('Desktop Linux release must not install the Ayatana appindicator dev package');
|
||||
}
|
||||
if (!linuxInstallLines.some((line) => line.includes('libappindicator3-dev'))) {
|
||||
throw new Error('Desktop Linux release must install libappindicator3-dev');
|
||||
}
|
||||
const linuxdeployLines = lines.filter((line) => line.includes('github.com/linuxdeploy/linuxdeploy/releases/download'));
|
||||
if (!linuxdeployLines.some((line) => line.includes('1-alpha-20250213-2/linuxdeploy-x86_64.AppImage'))) {
|
||||
throw new Error('Desktop Linux release must pin linuxdeploy 1-alpha-20250213-2');
|
||||
}
|
||||
// A pinned version/path is reproducibility, not integrity: the asset
|
||||
// can be replaced after upload. Require the immutable SHA-256 digest
|
||||
// to be pinned AND verified before chmod +x. Scope every check to the
|
||||
// real "Pin linuxdeploy for AppImage" step so this guard cannot
|
||||
// satisfy itself; a file-wide scan would match the guard's own code.
|
||||
const expectedLinuxdeployDigest = '4648f278ab3ef31f819e67c30d50f462640e5365a77637d7e6f2ad9fd0b4522a';
|
||||
const isComment = (line) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed.startsWith('#') || trimmed.startsWith('//');
|
||||
};
|
||||
const stepStart = lines.findIndex((line) => /^\s*- name: Pin linuxdeploy for AppImage\s*$/.test(line));
|
||||
if (stepStart === -1) {
|
||||
throw new Error('Desktop Linux release must keep the "Pin linuxdeploy for AppImage" step');
|
||||
}
|
||||
const stepIndent = lines[stepStart].search(/\S/);
|
||||
let stepEnd = lines.length;
|
||||
for (let i = stepStart + 1; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') continue;
|
||||
const indent = line.search(/\S/);
|
||||
// The next sibling step ('- ...') at the same indent, or any dedent
|
||||
// below the step, ends this step's block.
|
||||
if (indent < stepIndent || (indent === stepIndent && /^\s*-\s/.test(line))) {
|
||||
stepEnd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const stepLines = lines.slice(stepStart, stepEnd);
|
||||
const digestEnvRe = /^\s*LINUXDEPLOY_SHA256:\s*["']([0-9a-f]{64})["']\s*$/;
|
||||
const digestEnvLine = stepLines.find((line) => digestEnvRe.test(line));
|
||||
if (!digestEnvLine || digestEnvLine.match(digestEnvRe)[1] !== expectedLinuxdeployDigest) {
|
||||
throw new Error('Desktop Linux release must pin the linuxdeploy SHA-256 digest in the LINUXDEPLOY_SHA256 env');
|
||||
}
|
||||
const sha256Idx = stepLines.findIndex((line) => !isComment(line) && line.includes('sha256sum -c'));
|
||||
if (sha256Idx === -1) {
|
||||
throw new Error('Desktop Linux release must verify the linuxdeploy digest with sha256sum -c before use');
|
||||
}
|
||||
const chmodIdx = stepLines.findIndex((line) => !isComment(line) && /chmod\s+\+x/.test(line));
|
||||
if (chmodIdx !== -1 && sha256Idx > chmodIdx) {
|
||||
throw new Error('Desktop Linux release must verify the linuxdeploy digest before chmod +x');
|
||||
}
|
||||
const releaseBodies = [];
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/);
|
||||
if (!match) continue;
|
||||
const baseIndent = match[1].length;
|
||||
const bodyLines = [];
|
||||
i += 1;
|
||||
for (; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') {
|
||||
bodyLines.push('');
|
||||
continue;
|
||||
}
|
||||
const indent = line.match(/^\s*/)[0].length;
|
||||
if (indent <= baseIndent) {
|
||||
i -= 1;
|
||||
break;
|
||||
}
|
||||
bodyLines.push(line.slice(baseIndent + 2));
|
||||
}
|
||||
releaseBodies.push(bodyLines.join('\n'));
|
||||
}
|
||||
if (releaseBodies.length === 0) {
|
||||
throw new Error('Expected at least one desktop release body');
|
||||
}
|
||||
for (const body of releaseBodies) {
|
||||
if (/\brpm\b|\.rpm/i.test(body)) {
|
||||
throw new Error('Desktop release body must not advertise RPM packages');
|
||||
}
|
||||
if (/AppImage.*universal|universal.*AppImage/i.test(body)) {
|
||||
throw new Error('Desktop release body must not advertise AppImage as universal');
|
||||
}
|
||||
if (!/AppImage.*experimental/i.test(body)) {
|
||||
throw new Error('Desktop release body must mark AppImage as experimental');
|
||||
}
|
||||
}
|
||||
JS
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: studio/frontend
|
||||
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
|
||||
# required for `vite build`. The pre-install lockfile structural
|
||||
# audit (lockfile_supply_chain_audit.py) is the practical defence
|
||||
# against the npm postinstall-dropper class -- it fires BEFORE any
|
||||
# tarball runs, on the injection pattern itself rather than an
|
||||
# advisory-DB lookup.
|
||||
run: npm install --no-fund --no-audit
|
||||
|
||||
# ── Rust ──
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Patch desktop app version
|
||||
shell: bash
|
||||
working-directory: studio/src-tauri
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON=python3
|
||||
else
|
||||
PYTHON=python
|
||||
fi
|
||||
"$PYTHON" <<'PY'
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
app_version = os.environ['APP_VERSION']
|
||||
if not app_version:
|
||||
sys.exit('APP_VERSION is required')
|
||||
|
||||
cargo_toml = pathlib.Path('Cargo.toml')
|
||||
lines = cargo_toml.read_text().splitlines(keepends=True)
|
||||
in_package = False
|
||||
patched = False
|
||||
for index, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped == '[package]':
|
||||
in_package = True
|
||||
continue
|
||||
if stripped.startswith('[') and stripped.endswith(']'):
|
||||
in_package = False
|
||||
if in_package and re.fullmatch(r'version\s*=\s*"[^"]+"\s*', stripped):
|
||||
lines[index] = f'version = "{app_version}"\n'
|
||||
patched = True
|
||||
break
|
||||
if not patched:
|
||||
sys.exit('Could not patch [package] version in Cargo.toml')
|
||||
cargo_toml.write_text(''.join(lines))
|
||||
|
||||
cargo_lock = pathlib.Path('Cargo.lock')
|
||||
lock_text = cargo_lock.read_text()
|
||||
lock_text, count = re.subn(
|
||||
r'(?m)(^\[\[package\]\]\nname = "unsloth-studio"\nversion = ")[^"]+(")',
|
||||
lambda match: f'{match.group(1)}{app_version}{match.group(2)}',
|
||||
lock_text,
|
||||
)
|
||||
if count != 1:
|
||||
sys.exit(f'Could not patch unsloth-studio version in Cargo.lock (matches={count})')
|
||||
cargo_lock.write_text(lock_text)
|
||||
PY
|
||||
|
||||
cargo metadata --locked --no-deps --format-version 1 > "$RUNNER_TEMP/cargo-metadata.json"
|
||||
"$PYTHON" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
app_version = os.environ['APP_VERSION']
|
||||
metadata = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'cargo-metadata.json').read_text())
|
||||
versions = [package['version'] for package in metadata.get('packages', []) if package.get('name') == 'unsloth-studio']
|
||||
if versions != [app_version]:
|
||||
sys.exit(f'cargo metadata unsloth-studio version mismatch: expected {app_version}, got {versions}')
|
||||
PY
|
||||
|
||||
git diff -- Cargo.toml Cargo.lock
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
||||
with:
|
||||
workspaces: 'studio/src-tauri -> target'
|
||||
|
||||
# ── macOS: import signing certificate ──
|
||||
- name: Import Apple certificate
|
||||
if: matrix.platform == 'macos-latest'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security set-keychain-settings -t 3600 -u build.keychain
|
||||
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security find-identity -v -p codesigning build.keychain
|
||||
rm -f certificate.p12
|
||||
|
||||
# ── Windows: install Azure Trusted Signing CLI ──
|
||||
- name: Install trusted-signing-cli
|
||||
if: matrix.platform == 'windows-latest'
|
||||
run: |
|
||||
cargo install trusted-signing-cli --version 0.10.0 --locked
|
||||
echo "$env:USERPROFILE\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
# ── Windows: verify signing CLI is accessible ──
|
||||
- name: Verify trusted-signing-cli
|
||||
if: matrix.platform == 'windows-latest'
|
||||
run: |
|
||||
Write-Output "PATH: $env:PATH"
|
||||
Get-Command trusted-signing-cli -ErrorAction SilentlyContinue || Write-Output "trusted-signing-cli NOT in PATH"
|
||||
trusted-signing-cli --version || Write-Output "trusted-signing-cli failed to run"
|
||||
|
||||
# ── Linux: pin AppImage packaging toolchain ──
|
||||
- name: Pin linuxdeploy for AppImage
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
shell: bash
|
||||
env:
|
||||
# Pinning the versioned release path is reproducibility, not
|
||||
# integrity: a GitHub release asset can be replaced (or its delivery
|
||||
# path compromised) after upload. The SHA-256 below is the immutable
|
||||
# digest of this exact asset and is the integrity gate. If linuxdeploy
|
||||
# publishes a new build under this tag, this run fails closed and the
|
||||
# digest must be re-pinned deliberately.
|
||||
LINUXDEPLOY_URL: "https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20250213-2/linuxdeploy-x86_64.AppImage"
|
||||
LINUXDEPLOY_SHA256: "4648f278ab3ef31f819e67c30d50f462640e5365a77637d7e6f2ad9fd0b4522a"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tools_dir="$RUNNER_TEMP/tauri-tools-cache/tauri"
|
||||
mkdir -p "$tools_dir"
|
||||
dest="$tools_dir/linuxdeploy-x86_64.AppImage"
|
||||
curl -fsSL "$LINUXDEPLOY_URL" -o "$dest"
|
||||
# Verify the digest BEFORE the binary is ever marked executable. The
|
||||
# next step builds the AppImage with the Tauri signing key and a
|
||||
# contents:write GITHUB_TOKEN in scope, so a substituted linuxdeploy
|
||||
# that ran here could exfiltrate signing material or tamper with
|
||||
# published release artifacts. Fail closed on any mismatch.
|
||||
echo "${LINUXDEPLOY_SHA256} ${dest}" | sha256sum -c -
|
||||
chmod +x "$dest"
|
||||
|
||||
# ── Linux: build + sign + upload ──
|
||||
- name: Build Linux app
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/tauri-tools-cache
|
||||
with:
|
||||
projectPath: studio
|
||||
tauriScript: npx --prefix . tauri
|
||||
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
||||
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
|
||||
releaseBody: |
|
||||
Desktop app for Unsloth Studio.
|
||||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
||||
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
||||
releaseDraft: ${{ inputs.draft }}
|
||||
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
args: -v ${{ matrix.args }}
|
||||
|
||||
# ── macOS: build + sign + notarize + upload ──
|
||||
- name: Build macOS app
|
||||
if: matrix.platform == 'macos-latest'
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
projectPath: studio
|
||||
tauriScript: npx --prefix . tauri
|
||||
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
||||
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
|
||||
releaseBody: |
|
||||
Desktop app for Unsloth Studio.
|
||||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
||||
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
||||
releaseDraft: ${{ inputs.draft }}
|
||||
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
args: -v ${{ matrix.args }}
|
||||
|
||||
# ── Windows: build + sign + upload ──
|
||||
- name: Build Windows app
|
||||
if: matrix.platform == 'windows-latest'
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
AZURE_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }}
|
||||
with:
|
||||
projectPath: studio
|
||||
tauriScript: npx --prefix . tauri
|
||||
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
||||
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
|
||||
releaseBody: |
|
||||
Desktop app for Unsloth Studio.
|
||||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
||||
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
||||
releaseDraft: ${{ inputs.draft }}
|
||||
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
args: -v ${{ matrix.args }}
|
||||
|
||||
# Release process note: only non-draft workflow runs advance the public
|
||||
# desktop-latest updater channel. Draft builds are for private review; if a
|
||||
# draft is manually published later, this channel intentionally remains
|
||||
# unchanged until a narrow manual channel-publish flow is added or a public
|
||||
# desktop release is created by running this workflow with draft=false.
|
||||
publish-updater-channel:
|
||||
name: Publish desktop updater channel
|
||||
needs: [prepare-version, build]
|
||||
if: ${{ !inputs.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
|
||||
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
|
||||
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
||||
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
|
||||
steps:
|
||||
- name: Download versioned updater metadata
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/desktop-updater"
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${DESKTOP_RELEASE_TAG}" > "$RUNNER_TEMP/source-release.json"
|
||||
python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
source = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'source-release.json').read_text())
|
||||
expected_tag = os.environ['DESKTOP_RELEASE_TAG']
|
||||
if source.get('tag_name') != expected_tag:
|
||||
sys.exit(f'Expected source release {expected_tag}, got {source.get("tag_name")}')
|
||||
if source.get('draft'):
|
||||
sys.exit(f'Source desktop release {expected_tag} is draft; refusing to publish public updater channel')
|
||||
PY
|
||||
gh release download "$DESKTOP_RELEASE_TAG" --pattern latest.json --dir "$RUNNER_TEMP/desktop-updater" --clobber
|
||||
test -s "$RUNNER_TEMP/desktop-updater/latest.json"
|
||||
|
||||
- name: Validate versioned updater metadata
|
||||
shell: bash
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
app_version = os.environ['APP_VERSION']
|
||||
release_tag = os.environ['DESKTOP_RELEASE_TAG']
|
||||
latest_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-updater', 'latest.json')
|
||||
data = json.loads(latest_path.read_text())
|
||||
if not isinstance(data, dict):
|
||||
sys.exit('latest.json must be a JSON object')
|
||||
|
||||
version = data.get('version')
|
||||
if not isinstance(version, str) or not version:
|
||||
sys.exit('latest.json missing version')
|
||||
if not re.fullmatch(r'v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', version):
|
||||
sys.exit(f'latest.json version is not SemVer-like: {version}')
|
||||
if version.removeprefix('v') != app_version:
|
||||
sys.exit(f'latest.json version {version} does not match desktop app version {app_version}')
|
||||
|
||||
platforms = data.get('platforms')
|
||||
if not isinstance(platforms, dict) or not platforms:
|
||||
sys.exit('latest.json missing platforms')
|
||||
|
||||
required_families = {
|
||||
'darwin-aarch64': False,
|
||||
'linux-x86_64': False,
|
||||
'windows-x86_64': False,
|
||||
}
|
||||
expected_prefix = f'https://github.com/unslothai/unsloth/releases/download/{release_tag}/'
|
||||
forbidden_fragments = ('/releases/latest/', '/releases/download/desktop-latest/')
|
||||
|
||||
for platform, entry in platforms.items():
|
||||
if not isinstance(entry, dict):
|
||||
sys.exit(f'Platform {platform} must be an object')
|
||||
url = entry.get('url')
|
||||
signature = entry.get('signature')
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
sys.exit(f'Platform {platform} missing url')
|
||||
if not isinstance(signature, str) or not signature.strip():
|
||||
sys.exit(f'Platform {platform} missing signature')
|
||||
if any(fragment in url for fragment in forbidden_fragments):
|
||||
sys.exit(f'Platform {platform} points at a moving updater channel: {url}')
|
||||
if not url.startswith(expected_prefix):
|
||||
sys.exit(f'Platform {platform} URL must point at {release_tag}: {url}')
|
||||
for family in required_families:
|
||||
if platform == family or platform.startswith(family + '-'):
|
||||
required_families[family] = True
|
||||
|
||||
missing = [family for family, found in required_families.items() if not found]
|
||||
if missing:
|
||||
sys.exit('latest.json missing required platform families: ' + ', '.join(missing))
|
||||
PY
|
||||
|
||||
- name: Ensure desktop updater channel release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
channel_json="$RUNNER_TEMP/desktop-latest-release.json"
|
||||
if ! gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$channel_json" 2>/dev/null; then
|
||||
gh release create desktop-latest \
|
||||
--title "Unsloth Studio Desktop updater channel" \
|
||||
--notes "Machine-managed desktop updater channel; latest.json is replaced by release-desktop.yml." \
|
||||
--prerelease \
|
||||
--latest=false \
|
||||
--target "$GITHUB_SHA"
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$channel_json"
|
||||
fi
|
||||
|
||||
python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
channel = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-latest-release.json').read_text())
|
||||
if channel.get('draft'):
|
||||
sys.exit('desktop-latest release is draft; refusing to publish updater channel')
|
||||
if channel.get('immutable'):
|
||||
sys.exit('desktop-latest release is immutable; cannot replace latest.json')
|
||||
if not channel.get('prerelease'):
|
||||
sys.exit('desktop-latest release must be a prerelease so it cannot compete with repo-wide latest')
|
||||
PY
|
||||
|
||||
- name: Prevent updater channel downgrade
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/desktop-current"
|
||||
if ! gh release download desktop-latest --pattern latest.json --dir "$RUNNER_TEMP/desktop-current" --clobber 2>/dev/null; then
|
||||
echo "No existing desktop-latest latest.json found; allowing first channel publish."
|
||||
exit 0
|
||||
fi
|
||||
python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
def parse(value: str):
|
||||
value = value.removeprefix('v')
|
||||
match = re.fullmatch(
|
||||
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
|
||||
r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
|
||||
r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?',
|
||||
value,
|
||||
)
|
||||
if not match:
|
||||
sys.exit(f'desktop-latest latest.json has invalid version: {value}')
|
||||
major, minor, patch, prerelease = match.groups()
|
||||
return (int(major), int(minor), int(patch), prerelease)
|
||||
|
||||
def numeric_tail(identifier: str) -> tuple[str, int] | None:
|
||||
match = re.fullmatch(r'([A-Za-z-]+)(\d+)', identifier)
|
||||
if not match:
|
||||
return None
|
||||
return (match.group(1).lower(), int(match.group(2)))
|
||||
|
||||
def compare_identifier(left: str, right: str) -> int:
|
||||
left_num = left.isdigit()
|
||||
right_num = right.isdigit()
|
||||
if left_num and right_num:
|
||||
return (int(left) > int(right)) - (int(left) < int(right))
|
||||
if left_num:
|
||||
return -1
|
||||
if right_num:
|
||||
return 1
|
||||
|
||||
left_tail = numeric_tail(left)
|
||||
right_tail = numeric_tail(right)
|
||||
if left_tail and right_tail and left_tail[0] == right_tail[0]:
|
||||
return (left_tail[1] > right_tail[1]) - (left_tail[1] < right_tail[1])
|
||||
|
||||
return (left > right) - (left < right)
|
||||
|
||||
def compare_prerelease(left: str | None, right: str | None) -> int:
|
||||
if left == right:
|
||||
return 0
|
||||
if left is None:
|
||||
return 1
|
||||
if right is None:
|
||||
return -1
|
||||
left_parts = left.split('.')
|
||||
right_parts = right.split('.')
|
||||
for left_part, right_part in zip(left_parts, right_parts):
|
||||
order = compare_identifier(left_part, right_part)
|
||||
if order:
|
||||
return order
|
||||
return (len(left_parts) > len(right_parts)) - (len(left_parts) < len(right_parts))
|
||||
|
||||
def compare(left: str, right: str) -> int:
|
||||
left_major, left_minor, left_patch, left_pre = parse(left)
|
||||
right_major, right_minor, right_patch, right_pre = parse(right)
|
||||
left_core = (left_major, left_minor, left_patch)
|
||||
right_core = (right_major, right_minor, right_patch)
|
||||
if left_core != right_core:
|
||||
return (left_core > right_core) - (left_core < right_core)
|
||||
return compare_prerelease(left_pre, right_pre)
|
||||
|
||||
current_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-current', 'latest.json')
|
||||
next_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-updater', 'latest.json')
|
||||
current = json.loads(current_path.read_text()).get('version')
|
||||
next_version = json.loads(next_path.read_text()).get('version')
|
||||
if not isinstance(current, str) or not isinstance(next_version, str):
|
||||
sys.exit('Could not compare desktop-latest channel versions')
|
||||
if compare(next_version, current) < 0:
|
||||
sys.exit(
|
||||
f'Refusing to move desktop-latest from {current} to older version {next_version}.'
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Publish desktop updater channel metadata
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload desktop-latest "$RUNNER_TEMP/desktop-updater/latest.json" --clobber
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/desktop-latest" > "$RUNNER_TEMP/desktop-latest-release.json"
|
||||
python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
channel = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-latest-release.json').read_text())
|
||||
assets = [asset for asset in channel.get('assets', []) if asset.get('name') == 'latest.json']
|
||||
if len(assets) != 1:
|
||||
sys.exit(f'Expected exactly one desktop-latest latest.json asset, found {len(assets)}')
|
||||
expected_url = f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/desktop-latest/latest.json'
|
||||
actual_url = assets[0].get('browser_download_url')
|
||||
if actual_url != expected_url:
|
||||
sys.exit(f'desktop-latest latest.json URL mismatch: expected {expected_url}, got {actual_url}')
|
||||
PY
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
name: 'Inactive Issue Pinger'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 5 * * *' # Runs at 5:30 UTC every day
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
# The message to post on stale issues.
|
||||
# This message will ping the issue author.
|
||||
# Note: The stale bot action does not currently support a direct placeholder for the last commenter.
|
||||
# As a workaround, this message encourages any participant to reply.
|
||||
stale-issue-message: >
|
||||
Is this issue still important to you?
|
||||
Apologies in advance we might have missed this issue as well.
|
||||
For faster response times, please post on our Reddit server - https://www.reddit.com/r/unsloth or our Discord - https://discord.com/invite/unsloth
|
||||
|
||||
# The number of days of inactivity before an issue is considered stale.
|
||||
days-before-issue-stale: 9999
|
||||
|
||||
# Set to -1 to never close stale issues.
|
||||
days-before-issue-close: -1
|
||||
|
||||
# A label to apply to stale issues.
|
||||
stale-issue-label: 'inactive'
|
||||
|
||||
# The number of operations to perform per run to avoid rate limiting.
|
||||
operations-per-run: 500
|
||||
|
||||
enable-statistics: false
|
||||
@@ -0,0 +1,170 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Studio API & Auth Tests -- HTTP-level integration tests for the
|
||||
# FastAPI surface. No Playwright, no model UI; tests/studio/test_studio_api_smoke.py
|
||||
# runs ~30 s and asserts:
|
||||
# - CORS hardening (no wildcard + credentials, no bootstrap leak)
|
||||
# - /api/system + /api/system/hardware require auth
|
||||
# - Auth state machine + JWT expiry
|
||||
# - API key lifecycle E2E (create / list / use / delete / reject)
|
||||
# - Auth file-mode hardening (Linux only)
|
||||
# - Inference lifecycle (force reload, bogus variant, /v1/models, /v1/embeddings, /v1/responses)
|
||||
# - Endpoint-by-endpoint auth audit
|
||||
#
|
||||
# Reuses the GGUF cache key from studio-ui-smoke.yml so the model
|
||||
# download is one cache-hit on the second job.
|
||||
|
||||
name: Studio API CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.sh'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio/**'
|
||||
- '.github/workflows/studio-api-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api-smoke:
|
||||
name: Studio API & Auth Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 12
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18893'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Linux deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev libssl-dev jq
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: hf-cache
|
||||
# Same key as studio-ui-smoke.yml so the two jobs share a
|
||||
# single GGUF download across CI.
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
|
||||
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
|
||||
|
||||
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
|
||||
if: always() && steps.prime-hf.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Install pyjwt for the JWT-expiry forge test
|
||||
run: pip install 'pyjwt>=2.6'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
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 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
|
||||
- name: Pass bootstrap password + rotated targets to the test
|
||||
# The test does its own bootstrap-login + rotation to exercise
|
||||
# the auth state machine; we just pre-mint two random rotated
|
||||
# passwords for it. Mask them so the log is clean.
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
NEW2="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "::add-mask::$NEW2"
|
||||
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Studio API & Auth tests
|
||||
# The script is named WITHOUT a `test_` prefix so it isn't
|
||||
# auto-collected by pytest in Backend CI's `tests/` walk
|
||||
# (which doesn't set BASE_URL and would crash at import).
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18893
|
||||
STUDIO_AUTH_DIR: /home/runner/.unsloth/studio/auth
|
||||
run: python tests/studio/studio_api_smoke.py
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Upload API smoke logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-api-smoke-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/studio.log
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,240 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Runs the existing studio/backend/tests/ suite (~860 tests, all CPU-friendly)
|
||||
# on every PR that touches the backend or unsloth library. Until this lands,
|
||||
# none of those tests run automatically. Verified locally on Python 3.13 with
|
||||
# the surgical exclusions below: 861 pass, 4 skipped.
|
||||
#
|
||||
# Exclusions:
|
||||
# - tests/test_studio_api.py: end-to-end against a live model + GGUF download,
|
||||
# too heavy for free runners. Run separately when GPU CI is available.
|
||||
# - -k 'not llama_cpp_load_progress_live': spawns a real llama.cpp process,
|
||||
# not appropriate for CPU-only runners.
|
||||
#
|
||||
# Two jobs:
|
||||
# - pytest matrix (3.10/3.11/3.12/3.13) over studio/backend/tests
|
||||
# - repo-cpu-tests: auto-discovered tests/ + state-isolated spoof files
|
||||
#
|
||||
# Whole-repo Python lint (syntax + ruff + debugger-leftover scan)
|
||||
# moved to the dedicated `Lint CI` workflow (.github/workflows/lint-ci.yml)
|
||||
# so it fires on every PR rather than only on studio/unsloth/tests
|
||||
# path changes.
|
||||
|
||||
name: Backend CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'tests/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/studio-backend-ci.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
name: (Python ${{ matrix.python }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python: ['3.10', '3.11', '3.12', '3.13']
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '${{ matrix.python }}'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install backend test dependencies (CPU only)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Studio's declared backend deps:
|
||||
pip install -r studio/backend/requirements/studio.txt
|
||||
# Extras that studio.txt does not list but the import chain needs
|
||||
# (python-multipart for FastAPI form/file uploads, sqlalchemy/cryptography
|
||||
# for the auth DB, yaml/jinja2 for utils.models.model_config, psutil for
|
||||
# the orphan-cleanup process scan, etc.):
|
||||
pip install \
|
||||
python-multipart aiofiles sqlalchemy cryptography psutil \
|
||||
pyyaml jinja2 mammoth unpdf requests \
|
||||
'numpy<3' pytest pytest-asyncio httpx
|
||||
# Torch CPU + transformers are required by a chunk of the backend test
|
||||
# suite (gpu_selection, kv_cache_estimation, utils). CPU-only torch
|
||||
# keeps the install ~250 MB / ~1 min on a clean runner.
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple 'torch>=2.4,<2.11'
|
||||
pip install 'transformers>=4.51,<5.5'
|
||||
|
||||
- name: Backend tests
|
||||
working-directory: studio/backend
|
||||
# Locally validated against this dep set: 831 passed, 5 skipped, 35 deselected.
|
||||
# Deselections (all environment-specific, would never pass on a GPU-less
|
||||
# `ubuntu-latest` runner regardless of code correctness):
|
||||
# - llama_cpp_load_progress_live: spawns a real llama.cpp process
|
||||
# - TestGpuAutoSelection / TestPreSpawnGpuResolution / TestPerGpuFitGuardAllCounts:
|
||||
# require live transformers config introspection on real GPUs
|
||||
# - TestTransformersIntrospection: same
|
||||
# - test_returns_cuda_when_cuda_available / test_calls_cuda_cache_when_cuda:
|
||||
# assume CUDA-capable GPU
|
||||
run: |
|
||||
python -m pytest tests/ -q --tb=short \
|
||||
--ignore=tests/test_studio_api.py \
|
||||
-k 'not llama_cpp_load_progress_live and not TestGpuAutoSelection and not TestPreSpawnGpuResolution and not TestPerGpuFitGuardAllCounts and not TestTransformersIntrospection and not test_returns_cuda_when_cuda_available and not test_calls_cuda_cache_when_cuda'
|
||||
|
||||
repo-cpu-tests:
|
||||
# Auto-discover everything under tests/ that is not GPU-bound by
|
||||
# design. New tests added in covered directories are picked up
|
||||
# without a workflow edit. Locally validated: 760 passed, 1 skipped,
|
||||
# 23 deselected. tests/conftest.py (mirroring unsloth-zoo PR #624)
|
||||
# pre-loads unsloth_zoo.device_type and unsloth.device_type under a
|
||||
# mocked torch.cuda.is_available so the unsloth import chain
|
||||
# succeeds on CPU.
|
||||
name: Repo tests (CPU)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
# node + uv unlock ~60 tests that previously skipped on CI:
|
||||
# - 9 tests in test_chat_preset_builtin_invariants.py need node to
|
||||
# compile a tiny TS harness against the frontend chat sources.
|
||||
# - tests/python/* spawn fresh `uv venv`s to verify the no-torch
|
||||
# install path; they self-skip when uv is missing.
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install uv (for tests/python/* sandboxed venvs)
|
||||
run: pip install uv
|
||||
|
||||
- name: Install deps (shared shape with backend pytest job)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r studio/backend/requirements/studio.txt
|
||||
pip install \
|
||||
python-multipart aiofiles sqlalchemy cryptography psutil \
|
||||
pyyaml jinja2 mammoth unpdf requests typer \
|
||||
'numpy<3' pytest pytest-asyncio httpx
|
||||
# torchvision: unsloth_zoo.vision_utils imports it at module scope.
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
|
||||
'torch>=2.4,<2.11' 'torchvision<0.26'
|
||||
pip install 'transformers>=4.51,<5.5'
|
||||
# bitsandbytes: hard import in unsloth/models/_utils.py. Recent
|
||||
# versions ship a CPU build that imports cleanly on Linux.
|
||||
pip install 'bitsandbytes>=0.45'
|
||||
# unsloth.device_type imports unsloth_zoo.utils.Version at module
|
||||
# scope, so the conftest preload needs unsloth_zoo. Pull from
|
||||
# git main so this job sees the same zoo HEAD as Core / MLX /
|
||||
# install.sh do (otherwise a fix on zoo main hides until release).
|
||||
# No --no-deps: matches prior `pip install 'unsloth_zoo>=2026.5.1'`
|
||||
# behaviour so triton etc. still come in for the Repo tests CPU
|
||||
# collection imports.
|
||||
for attempt in 1 2 3; do
|
||||
if pip install "unsloth_zoo @ git+https://github.com/unslothai/unsloth-zoo"; then
|
||||
break
|
||||
fi
|
||||
[ "$attempt" -eq 3 ] && { echo "::error::unsloth_zoo install failed after 3 attempts"; exit 1; }
|
||||
sleep $((5 * attempt))
|
||||
done
|
||||
pip install -e . --no-deps
|
||||
|
||||
- name: Repo tests (CPU, auto-discovered)
|
||||
env:
|
||||
# tests/python/* import install_python_stack from studio/.
|
||||
PYTHONPATH: ${{ github.workspace }}/studio
|
||||
# Skip lazy compilation work the unsloth import chain wants to
|
||||
# do at import time on a real GPU.
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
# --ignore: GPU-bound directories (qlora/saving need real weights;
|
||||
# tests/sh is the shell suite the next step handles; tests/utils
|
||||
# is a helpers folder); tests/vllm_compat + tests/version_compat
|
||||
# are dedicated multi-version drift canaries with their own job
|
||||
# in version-compat-ci.yml that installs the heavier dep set
|
||||
# (torchcodec, full transformers/peft/bnb pins) those tests need.
|
||||
# State-sensitive hardware-spoofing files run in isolation in the
|
||||
# next step because they mutate hardware.py module globals.
|
||||
# -m: honour markers from tests/python/conftest.py (`server` =
|
||||
# needs studio venv, `e2e` = needs network).
|
||||
# --deselect:
|
||||
# - test_model_registration / test_all_model_registration:
|
||||
# hit huggingface_hub for live model existence checks.
|
||||
# - test_autoconfig_works_with_no_torch_runtime / test_autoconfig_succeeds:
|
||||
# fail because no-torch-runtime.txt does not pin tokenizers
|
||||
# and the latest tokenizers (0.23.1) is incompatible with the
|
||||
# transformers it resolves to. Tracked separately; this is a
|
||||
# real bug in the no-torch install path, not a CI issue.
|
||||
run: |
|
||||
python -m pytest tests/ -q --tb=short \
|
||||
--ignore=tests/qlora \
|
||||
--ignore=tests/saving \
|
||||
--ignore=tests/utils \
|
||||
--ignore=tests/sh \
|
||||
--ignore=tests/studio/test_hardware_dispatch_matrix.py \
|
||||
--ignore=tests/studio/test_is_mlx_dispatch_gate.py \
|
||||
--ignore=tests/vllm_compat \
|
||||
--ignore=tests/version_compat \
|
||||
-m 'not server and not e2e' \
|
||||
--deselect tests/test_model_registry.py::test_model_registration \
|
||||
--deselect tests/test_model_registry.py::test_all_model_registration \
|
||||
--deselect 'tests/python/test_tokenizers_and_torch_constraint.py::TestE2ETokenizersFix::test_autoconfig_works_with_no_torch_runtime' \
|
||||
--deselect 'tests/python/test_tokenizers_and_torch_constraint.py::TestE2EFullNoTorchSandbox::test_autoconfig_succeeds'
|
||||
|
||||
- name: Hardware-spoof tests (state-sensitive, run in isolation)
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/studio
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
# These two files mutate hardware.py module globals at runtime
|
||||
# via the spoof fixtures, which leaks state into any other test
|
||||
# that imports hardware. Run them in their own pytest invocation
|
||||
# so the leak does not cross file boundaries.
|
||||
run: |
|
||||
python -m pytest -q --tb=short \
|
||||
tests/studio/test_hardware_dispatch_matrix.py \
|
||||
tests/studio/test_is_mlx_dispatch_gate.py
|
||||
|
||||
- name: Shell installer tests
|
||||
# Subset that does not depend on a writable / pristine install.sh
|
||||
# tree; test_install_host_defaults.sh checks install.ps1 layout
|
||||
# which has drifted (separate followup).
|
||||
run: |
|
||||
set -e
|
||||
for s in \
|
||||
tests/sh/test_get_torch_index_url.sh \
|
||||
tests/sh/test_mac_intel_compat.sh \
|
||||
tests/sh/test_node_decision.sh \
|
||||
tests/sh/test_studio_home_node_dir.sh \
|
||||
tests/sh/test_system_node_readonly.sh \
|
||||
tests/sh/test_nvcc_meets_llama_minimum.sh \
|
||||
tests/sh/test_resolve_cuda_archs.sh \
|
||||
tests/sh/test_tauri_install_exit_order.sh \
|
||||
tests/sh/test_torch_constraint.sh \
|
||||
tests/sh/test_torch_flavor.sh \
|
||||
tests/sh/test_with_llama_cpp_dir_flag.sh \
|
||||
tests/sh/test_with_llama_cpp_dir_link_behavior.sh; do
|
||||
echo "::group::$s"
|
||||
bash "$s"
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Runs studio/backend/tests/test_export_capability.py on Linux, Windows and macOS.
|
||||
#
|
||||
# export_capability() is per-OS (is_apple_silicon() and the PyTorch-import probe differ per
|
||||
# platform) and the export backend must import without PyTorch, so this confirms the gating and
|
||||
# import-safety on hosted Windows/macOS. Hosted runners have no GPU/MLX, so a real accelerator
|
||||
# export is validated separately. No GPU / model / llama.cpp: the tests mock the probes and block
|
||||
# torch/unsloth, so the job installs only a CPU PyTorch plus import deps.
|
||||
|
||||
name: Studio export capability
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/backend/utils/hardware/hardware.py'
|
||||
- 'studio/backend/core/export/export.py'
|
||||
- 'studio/backend/routes/export.py'
|
||||
- 'studio/backend/main.py'
|
||||
- 'studio/backend/tests/test_export_capability.py'
|
||||
- '.github/workflows/studio-export-capability-ci.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'studio/backend/utils/hardware/hardware.py'
|
||||
- 'studio/backend/core/export/export.py'
|
||||
- 'studio/backend/routes/export.py'
|
||||
- 'studio/backend/main.py'
|
||||
- 'studio/backend/tests/test_export_capability.py'
|
||||
- '.github/workflows/studio-export-capability-ci.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
capability:
|
||||
name: capability (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# No accelerator on hosted runners; keep detection on the CPU path.
|
||||
CUDA_VISIBLE_DEVICES: ""
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Upgrade pip
|
||||
run: python -m pip install --upgrade pip
|
||||
- name: Install CPU PyTorch
|
||||
# CPU wheel index so every OS gets a CPU build; keep PyPI as an extra index so torch's
|
||||
# transitive deps still resolve (matching the other workflows in this repo).
|
||||
run: python -m pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple "torch>=2.4,<2.13"
|
||||
- name: Install backend import deps
|
||||
# Enough to import utils.hardware and core.export.export; NOT unsloth (needs a GPU, and
|
||||
# the import-safety test blocks it) or triton/llama.cpp (Linux-only / native builds).
|
||||
run: python -m pip install
|
||||
transformers peft accelerate safetensors huggingface_hub datasets
|
||||
sentencepiece protobuf fastapi starlette structlog psutil
|
||||
python-multipart pydantic httpx "numpy<3" pytest
|
||||
- name: Export capability + import-safety tests
|
||||
working-directory: studio/backend
|
||||
run: python -m pytest tests/test_export_capability.py -q
|
||||
@@ -0,0 +1,175 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Frontend PR gate: lockfile freshness, typecheck, build, and a bundle grep
|
||||
# that catches the 2026.5.1 chat-history regression at the JS level.
|
||||
#
|
||||
# biome runs as non-blocking for now: the codebase currently has accumulated
|
||||
# ~470 errors and ~1650 warnings against the existing biome config. Surfacing
|
||||
# the count in CI lets us drive it down without forcing a fleet-wide cleanup
|
||||
# in the same PR. Drop `continue-on-error` once that number is zero.
|
||||
|
||||
name: Frontend CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/frontend/**'
|
||||
- 'scripts/check_frontend_dep_removal.py'
|
||||
- 'tests/studio/test_frontend_dep_removal.py'
|
||||
- 'scripts/sync_allow_scripts_pins.py'
|
||||
- 'tests/studio/test_sync_allow_scripts_pins.py'
|
||||
- '.github/workflows/studio-frontend-ci.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Frontend build + bundle sanity
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: studio/frontend
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# FIXME: drop this step once @assistant-ui/* and assistant-stream
|
||||
# leave 0.x -- on 1.x, caret ranges are conventional. Until then,
|
||||
# every 0.minor on this surface is a SemVer-major (this is exactly
|
||||
# how 2026.5.1 shipped a broken chat runtime: ^0.12.19 quietly
|
||||
# resolved to 0.12.28).
|
||||
- name: '@assistant-ui must be pinned exactly (no caret/tilde)'
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
set -e
|
||||
if grep -nE '"(@assistant-ui/[a-z-]+|assistant-stream)":[[:space:]]*"[\^~]' studio/frontend/package.json; then
|
||||
echo "::error file=studio/frontend/package.json::These packages must be pinned to exact versions until they leave 0.x. Drop the leading ^ or ~."
|
||||
exit 1
|
||||
fi
|
||||
echo "All assistant-ui packages are pinned exactly."
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# node 22 bundles npm 10.x, which predates allowScripts. Move to the
|
||||
# 11.x line and fail loudly if the gate is still missing, so the
|
||||
# strict flag below can never silently degrade into a warning.
|
||||
- name: Upgrade npm to 11.x (allowScripts enforcement)
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
npm install -g npm@^11 --no-fund --no-audit
|
||||
V=$(npm -v)
|
||||
case "$V" in
|
||||
11.1[6-9].*|11.[2-9][0-9].*|1[2-9].*) echo "npm $V has allowScripts" ;;
|
||||
*) echo "::error::npm $V lacks allowScripts (need >=11.16)"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Run the structural lockfile scan BEFORE npm ci. A compromised
|
||||
# tarball runs its `prepare` / `postinstall` during `npm ci`,
|
||||
# so any catch has to fire upstream of that. The scanner is
|
||||
# pure-Python read-only; safe to call ahead of every install.
|
||||
- name: Lockfile supply-chain audit (pre-install scan)
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: python3 scripts/lockfile_supply_chain_audit.py
|
||||
|
||||
# Dependency bumps strand the version-pinned allowScripts entries.
|
||||
# The paired pre-commit hook auto-fixes PRs; this is the backstop.
|
||||
- name: allowScripts pins must match the lockfile
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
python3 tests/studio/test_sync_allow_scripts_pins.py
|
||||
python3 scripts/sync_allow_scripts_pins.py --check
|
||||
|
||||
- name: Lockfile must agree with package.json (npm ci is strict)
|
||||
# The vite 8 chain (rolldown, lightningcss, tailwind oxide) ships napi
|
||||
# binaries with no install scripts. The only script-bearing deps are
|
||||
# covered by `allowScripts` in package.json (npm >=11.16, default in
|
||||
# npm 12). The pre-install lockfile audit above stays the first line
|
||||
# of defence -- it fires before any tarball can run code.
|
||||
# --strict-allow-scripts: any unreviewed install script hard-fails
|
||||
# the job; the sync hook keeps the pins fresh after bumps.
|
||||
run: npm ci --strict-allow-scripts --no-fund --no-audit
|
||||
|
||||
- name: npm ci must not have modified the working tree
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
if ! git diff --quiet -- studio/frontend; then
|
||||
echo "::error::npm ci modified files; commit the updated lockfile"
|
||||
git status -- studio/frontend
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Catch the common foot-gun: a dep dropped from package.json that is
|
||||
# still imported somewhere. The script walks the lockfile dep graph
|
||||
# from the new top-level deps and only counts top-level node_modules
|
||||
# paths as valid resolution targets for bare src/ imports.
|
||||
#
|
||||
# actions/checkout uses fetch-depth: 1 by default, so the base branch
|
||||
# is not available locally. Fetch the single base commit with an
|
||||
# explicit refspec so origin/<base> is reliably created (a bare
|
||||
# `git fetch origin <ref>` only updates FETCH_HEAD in some configs).
|
||||
- name: Dependency removal safety check
|
||||
if: github.event_name == 'pull_request'
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin \
|
||||
"${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}"
|
||||
python3 scripts/check_frontend_dep_removal.py \
|
||||
--base "origin/${{ github.base_ref }}" \
|
||||
--enumerate-dead
|
||||
python3 tests/studio/test_frontend_dep_removal.py
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Built bundle must not contain Studio's unstable_Provider call site
|
||||
run: |
|
||||
set -e
|
||||
JS=$(ls dist/assets/index-*.js | head -1)
|
||||
HITS=$(grep -c 'unstable_Provider:' "$JS" || echo 0)
|
||||
echo "main bundle: $JS"
|
||||
echo "unstable_Provider: hits=$HITS (assistant-ui internals contribute up to 3)"
|
||||
if [ "$HITS" -gt 3 ]; then
|
||||
echo "::error file=studio/frontend/src/features/chat/runtime-provider.tsx::Studio bundle still passes unstable_Provider through useRemoteThreadListRuntime; this is the 2026.5.1 chat-history regression. Pass adapters directly into useLocalRuntime instead."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Bundle size budget (75 MB)
|
||||
run: |
|
||||
SIZE=$(du -sb dist | cut -f1)
|
||||
BUDGET=$((75 * 1024 * 1024))
|
||||
echo "dist size: $SIZE bytes ($((SIZE/1024/1024)) MB), budget: $BUDGET bytes (75 MB)"
|
||||
if [ "$SIZE" -gt "$BUDGET" ]; then
|
||||
echo "::error::studio/frontend/dist/ exceeded the 75 MB budget. Drop dead deps (e.g. the unused next dep) or split chunks."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Biome (non-blocking until accumulated drift is cleared)
|
||||
continue-on-error: true
|
||||
run: npm run biome:check
|
||||
|
||||
- name: Upload built dist
|
||||
# Always upload so a green run is reviewable too -- the dist
|
||||
# output catches "tests passed but bundle changed unexpectedly"
|
||||
# regressions that would be invisible if we only kept artifacts
|
||||
# on failure.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-frontend-dist
|
||||
path: studio/frontend/dist
|
||||
retention-days: 3
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Event-loop regression test for the Studio model-load orchestrator.
|
||||
# Pins down issue #5642 (Win10 UI freeze on model load): the /load
|
||||
# route calls LlamaCppBackend.detect_audio_type synchronously, blocking
|
||||
# the FastAPI event loop on a chain of sync httpx.Client.post() probes.
|
||||
#
|
||||
# The suite stands up a stdlib fake llama-server + a tiny FastAPI app
|
||||
# via uvicorn and asserts that detect_audio_type runs via
|
||||
# asyncio.to_thread so concurrent /api/inference/load-progress polling
|
||||
# stays responsive. CPU-only, no torch, no real llama.cpp binary, no
|
||||
# GPU -- the matching cross-OS staging proof lives on
|
||||
# danielhanchen/unsloth-staging-2 (Ubuntu / macOS / Windows all
|
||||
# green at PR time).
|
||||
|
||||
name: Studio load-orchestrator CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/backend/routes/inference.py'
|
||||
- 'studio/backend/core/inference/llama_cpp.py'
|
||||
- 'tests/studio/load_freeze/**'
|
||||
- '.github/workflows/studio-load-orchestrator-ci.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'studio/backend/routes/inference.py'
|
||||
- 'studio/backend/core/inference/llama_cpp.py'
|
||||
- 'tests/studio/load_freeze/**'
|
||||
- '.github/workflows/studio-load-orchestrator-ci.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install minimal deps (no torch, no unsloth)
|
||||
# The test stubs `loggers` and `structlog`, imports
|
||||
# core.inference.llama_cpp directly, and drives a small
|
||||
# FastAPI app. Nothing here pulls torch or any GPU code,
|
||||
# so the entire job typically completes in well under 60 s.
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install \
|
||||
'pytest>=8' \
|
||||
'httpx>=0.27,<1' \
|
||||
'fastapi>=0.110,<1' \
|
||||
'uvicorn>=0.30,<1' \
|
||||
'anyio>=4'
|
||||
- name: Run load-orchestrator tests
|
||||
run: python -m pytest -v --tb=short tests/studio/load_freeze/
|
||||
@@ -0,0 +1,152 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Mac counterpart to studio-api-smoke.yml. Same tests/studio/
|
||||
# studio_api_smoke.py exercise (CORS hardening, auth state machine,
|
||||
# JWT expiry, API key lifecycle, /v1/models / /v1/embeddings /
|
||||
# /v1/responses, endpoint-by-endpoint auth audit) but on a real
|
||||
# Apple Silicon (macos-14, M1) runner. Drops the apt-get block;
|
||||
# GitHub-hosted macos-14 ships curl + jq.
|
||||
|
||||
name: Mac Studio API CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.sh'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio/**'
|
||||
- '.github/workflows/studio-mac-api-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api-smoke:
|
||||
name: Studio API & Auth Tests
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18895'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
|
||||
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
|
||||
|
||||
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
|
||||
if: always() && steps.prime-hf.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Assert llama.cpp loads on this macOS
|
||||
run: bash .github/scripts/assert-llama-loads.sh
|
||||
|
||||
- name: Install pyjwt for the JWT-expiry forge test
|
||||
run: pip install 'pyjwt>=2.6'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
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 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
|
||||
- name: Pass bootstrap password + rotated targets to the test
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
NEW2="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "::add-mask::$NEW2"
|
||||
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Studio API & Auth tests
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18895
|
||||
STUDIO_AUTH_DIR: /Users/runner/.unsloth/studio/auth
|
||||
run: python tests/studio/studio_api_smoke.py
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Upload API smoke logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: mac-studio-api-smoke-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/studio.log
|
||||
retention-days: 7
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Proves Studio's llama.cpp install loads on every supported macOS. The heavy
|
||||
# app smokes stay single-OS; this matrix covers the OS-version dimension cheaply
|
||||
# (install.sh + binary-load assert). Regression guard for the macOS-version
|
||||
# selection in studio/install_llama_prebuilt.py.
|
||||
|
||||
name: Mac Studio Install Matrix CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/install_llama_prebuilt.py'
|
||||
- 'studio/setup.sh'
|
||||
- 'install.sh'
|
||||
- '.github/scripts/assert-llama-loads.sh'
|
||||
- '.github/workflows/studio-mac-install-matrix.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
install-load:
|
||||
name: Install + load (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 25
|
||||
continue-on-error: ${{ matrix.experimental }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-14 # Apple Silicon, macOS 14 Sonoma
|
||||
experimental: false
|
||||
- os: macos-15 # Apple Silicon, macOS 15 Sequoia
|
||||
experimental: false
|
||||
- os: macos-26 # Apple Silicon, macOS 26 Tahoe
|
||||
experimental: false
|
||||
- os: macos-15-intel # Intel x86_64, macOS 15 (informational)
|
||||
experimental: true
|
||||
- os: macos-26-intel # Intel x86_64, macOS 26 (last Intel macOS)
|
||||
experimental: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Assert llama.cpp loads on this macOS
|
||||
run: bash .github/scripts/assert-llama-loads.sh
|
||||
|
||||
- name: Upload install log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: mac-install-matrix-${{ matrix.os }}-log
|
||||
path: logs/install.log
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,347 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Mac counterpart to studio-ui-smoke.yml. Same Playwright + Chromium
|
||||
# end-to-end chat UI flow, but on macos-14 (M1) so we catch
|
||||
# Mac-specific frontend / backend wiring regressions that the Linux
|
||||
# job would miss (e.g. the Mac Tauri shell loading the same React
|
||||
# bundle, or the Mac llama.cpp prebuilt's HTTP layer behaving
|
||||
# differently from the Linux build).
|
||||
|
||||
name: Mac Studio UI CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.sh'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio/**'
|
||||
- '.github/workflows/studio-mac-ui-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ui-smoke:
|
||||
name: Chat UI Tests
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 35
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18896'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
|
||||
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
|
||||
|
||||
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
|
||||
if: always() && steps.prime-hf.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Assert llama.cpp loads on this macOS
|
||||
run: bash .github/scripts/assert-llama-loads.sh
|
||||
|
||||
- name: Install Playwright + Chromium
|
||||
# No --with-deps on Mac: that flag installs Linux apt packages.
|
||||
# GitHub-hosted macos-14 ships the system frameworks Chromium
|
||||
# needs already.
|
||||
# Pinned <1.58 because all 1.55-1.58 drivers ship Node 24 on
|
||||
# macos-14 and intermittently hit 'SyntaxError: Unexpected end
|
||||
# of JSON input' in pipeTransport.js. Run 25491698868 showed
|
||||
# the crash hitting 100% of three retry attempts -- not a
|
||||
# rare race but a hard reproduction. Belt-and-suspenders fix:
|
||||
# the test scripts pass --single-process to Chromium (see
|
||||
# tests/studio/playwright_chat_ui.py) AND we patch
|
||||
# pipeTransport.js below to swallow JSON parse errors instead
|
||||
# of crashing the driver Node process. Both together let the
|
||||
# in-script retry recover from any residual flakes.
|
||||
run: |
|
||||
pip install 'playwright>=1.55,<1.58'
|
||||
python -m playwright install chromium
|
||||
|
||||
- name: Patch Playwright pipeTransport.js to tolerate malformed JSON
|
||||
# In Playwright 1.55-1.58, pipeTransport.js does
|
||||
# `JSON.parse(message)` with no try/catch; when Chromium dies
|
||||
# mid-write the partial buffer crashes the driver Node
|
||||
# process and the test script exits with 'Connection closed
|
||||
# while reading from the driver'. Newer Playwright versions
|
||||
# added a try/catch upstream. Backport that here.
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os, re, sys
|
||||
import playwright
|
||||
driver_dir = os.path.join(os.path.dirname(playwright.__file__), "driver", "package", "lib", "server")
|
||||
path = os.path.join(driver_dir, "pipeTransport.js")
|
||||
src = open(path).read()
|
||||
# Wrap both `this.onmessage.call(null, JSON.parse(...))` sites in try/catch.
|
||||
patched = re.sub(
|
||||
r"this\.onmessage\.call\(null, JSON\.parse\((message2?)\)\);",
|
||||
r"try { this.onmessage.call(null, JSON.parse(\1)); } "
|
||||
r"catch (e) { /* swallow malformed JSON from a crashing browser */ }",
|
||||
src,
|
||||
)
|
||||
if patched == src:
|
||||
# Already patched, or upstream changed -- either way, don't fail the build.
|
||||
print(f"pipeTransport.js: no JSON.parse calls matched at {path}; skipping.")
|
||||
else:
|
||||
open(path, "w").write(patched)
|
||||
print(f"pipeTransport.js: patched JSON.parse calls in {path}")
|
||||
PY
|
||||
|
||||
- name: Reset auth + boot Studio
|
||||
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 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
|
||||
- name: Pass bootstrap password to the Playwright step
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
NEW2="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "::add-mask::$NEW2"
|
||||
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive the chat UI with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18896
|
||||
PW_ART_DIR: logs/playwright
|
||||
STUDIO_UI_STRICT: '1'
|
||||
# macos-14 free runner is 3 vCPU / 7 GB / no Metal-accel
|
||||
# available to llama.cpp from CI; gemma-3-270m turn latency
|
||||
# has been observed to crowd the 180s default. Triple it.
|
||||
STUDIO_UI_TURN_TIMEOUT_MS: '540000'
|
||||
# Retry up to 3 times to absorb known macos-14 free-runner
|
||||
# flakes: (1) Playwright Node 24 pipeTransport.js 'Unexpected
|
||||
# end of JSON input' crash when the Chromium browser process
|
||||
# dies mid-test, (2) Chromium net::ERR_NO_BUFFER_SPACE when the
|
||||
# runner's kernel briefly runs out of socket buffers, and (3) a
|
||||
# goto 'interrupted by another navigation' when the SPA auth
|
||||
# guard redirects mid-navigation. The retry FULLY resets Studio
|
||||
# (kill, reset-password, reboot, wait /api/health, re-export
|
||||
# bootstrap pw) before re-running the script. A real test failure
|
||||
# (assertion / timeout) does NOT match any pattern so it bypasses
|
||||
# retry and surfaces immediately.
|
||||
run: |
|
||||
mkdir -p logs/playwright
|
||||
attempt=1
|
||||
max_attempts=3
|
||||
while : ; do
|
||||
set +e
|
||||
python tests/studio/playwright_chat_ui.py 2>&1 | tee logs/playwright_attempt_${attempt}.log
|
||||
rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
if { grep -q "Unexpected end of JSON input" logs/playwright_attempt_${attempt}.log \
|
||||
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log \
|
||||
|| grep -q "interrupted by another navigation" logs/playwright_attempt_${attempt}.log; } \
|
||||
&& [ "$attempt" -lt "$max_attempts" ]; then
|
||||
echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
unsloth studio reset-password
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> "logs/studio_retry_${attempt}.log" 2>&1 &
|
||||
STUDIO_PID=$!
|
||||
echo "STUDIO_PID=$STUDIO_PID" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json \
|
||||
&& jq -e '.status == "healthy"' /tmp/health.json >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
STUDIO_OLD_PW=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
STUDIO_NEW_PW="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
STUDIO_NEW2_PW="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$STUDIO_OLD_PW"
|
||||
echo "::add-mask::$STUDIO_NEW_PW"
|
||||
echo "::add-mask::$STUDIO_NEW2_PW"
|
||||
export STUDIO_OLD_PW STUDIO_NEW_PW STUDIO_NEW2_PW
|
||||
attempt=$((attempt + 1))
|
||||
sleep 3
|
||||
continue
|
||||
fi
|
||||
exit "$rc"
|
||||
done
|
||||
|
||||
- name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Reset auth + boot Studio for extra UI tests (port 18897)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
|
||||
> logs/studio_extra.log 2>&1 &
|
||||
echo "STUDIO_EXTRA_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health on 18897
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:18897/api/health" > /tmp/health2.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health2.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health2.json
|
||||
|
||||
- name: Pass bootstrap pw for extra UI test
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIUiExtra-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18897
|
||||
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
|
||||
STUDIO_NEW_PW: ${{ env.STUDIO_EXTRA_NEW_PW }}
|
||||
PW_ART_DIR: logs/playwright_extra
|
||||
STUDIO_UI_STRICT: '1'
|
||||
# See "Drive the chat UI" step.
|
||||
STUDIO_UI_TURN_TIMEOUT_MS: '540000'
|
||||
GGUF_REPO: ${{ env.GGUF_REPO }}
|
||||
GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
|
||||
# Same flake-retry shape as "Drive the chat UI with Playwright" -- catches
|
||||
# pipeTransport JSON crash, ERR_NO_BUFFER_SPACE, and nav interrupts.
|
||||
run: |
|
||||
mkdir -p logs/playwright_extra
|
||||
attempt=1
|
||||
max_attempts=3
|
||||
while : ; do
|
||||
set +e
|
||||
python tests/studio/playwright_extra_ui.py 2>&1 | tee logs/playwright_extra_attempt_${attempt}.log
|
||||
rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
break
|
||||
fi
|
||||
if { grep -q "Unexpected end of JSON input" logs/playwright_extra_attempt_${attempt}.log \
|
||||
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log \
|
||||
|| grep -q "interrupted by another navigation" logs/playwright_extra_attempt_${attempt}.log; } \
|
||||
&& [ "$attempt" -lt "$max_attempts" ]; then
|
||||
echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
unsloth studio reset-password
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
|
||||
> "logs/studio_extra_retry_${attempt}.log" 2>&1 &
|
||||
STUDIO_EXTRA_PID=$!
|
||||
echo "STUDIO_EXTRA_PID=$STUDIO_EXTRA_PID" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:18897/api/health" > /tmp/health2.json \
|
||||
&& jq -e '.status == "healthy"' /tmp/health2.json >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
STUDIO_OLD_PW=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
STUDIO_NEW_PW="CIUiExtra-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$STUDIO_OLD_PW"
|
||||
echo "::add-mask::$STUDIO_NEW_PW"
|
||||
export STUDIO_OLD_PW STUDIO_NEW_PW
|
||||
attempt=$((attempt + 1))
|
||||
sleep 3
|
||||
continue
|
||||
fi
|
||||
exit "$rc"
|
||||
done
|
||||
|
||||
- name: Stop second Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: mac-studio-ui-smoke-artifacts
|
||||
path: |
|
||||
logs/studio.log
|
||||
logs/studio_extra.log
|
||||
logs/install.log
|
||||
logs/playwright
|
||||
logs/playwright_extra
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,177 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Mac counterpart to studio-update-smoke.yml. Verifies that on a real
|
||||
# Apple Silicon (macos-14, M1) runner:
|
||||
#
|
||||
# 1. install.sh --local --no-torch installs Studio AND auto-fetches
|
||||
# the prebuilt llama.cpp Mac binary (llama-bNNNN-bin-macos-arm64
|
||||
# from ggml-org/llama.cpp). Hitting the source-build fallback is
|
||||
# treated as an Unsloth bug -- Studio must always pick the
|
||||
# prebuilt on Mac.
|
||||
# 2. unsloth studio update --local is idempotent. Two consecutive
|
||||
# runs both report "prebuilt up to date and validated", no
|
||||
# source-build fallback.
|
||||
# 3. The installed Studio still boots and /api/health returns
|
||||
# healthy after the update path.
|
||||
|
||||
name: Mac Studio Update CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'install.sh'
|
||||
- 'scripts/uninstall.sh'
|
||||
- 'studio/setup.sh'
|
||||
- 'studio/install_python_stack.py'
|
||||
- 'studio/install_llama_prebuilt.py'
|
||||
- 'studio/backend/requirements/**'
|
||||
- 'unsloth_cli/commands/studio.py'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/studio-mac-update-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-idempotency:
|
||||
name: Studio Updating Tests
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Assert llama.cpp loads on this macOS
|
||||
run: bash .github/scripts/assert-llama-loads.sh
|
||||
|
||||
- name: First update should be a no-op (prebuilt already validated)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update.log
|
||||
if grep -q "falling back to source build" logs/update.log; then
|
||||
echo "::error::studio update fell back to source-build llama.cpp on Mac."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update.log; then
|
||||
echo "::error::no prebuilt up-to-date marker in update.log."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
echo "update path took the prebuilt fast path"
|
||||
|
||||
- name: Second update must also be a no-op
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update2.log
|
||||
grep -q "falling back to source build" logs/update2.log && {
|
||||
echo "::error::second update fell back to source build on Mac"
|
||||
tail -60 logs/update2.log; exit 1; } || true
|
||||
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
|
||||
echo "second update was clean"
|
||||
|
||||
- name: Boot Studio briefly to confirm the install is still usable
|
||||
run: |
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \
|
||||
> logs/studio.log 2>&1 &
|
||||
PID=$!
|
||||
HEALTHY=""
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fs http://127.0.0.1:18891/api/health > /tmp/health.json; then
|
||||
if python3 -c "import json,sys; d=json.load(open('/tmp/health.json')); sys.exit(0 if d.get('status')=='healthy' else 1)"; then
|
||||
HEALTHY=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$HEALTHY" ]; then
|
||||
echo "Studio failed to come up after \`update\`"
|
||||
tail -200 logs/studio.log
|
||||
kill "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
kill "$PID" 2>/dev/null || true
|
||||
echo "post-update Studio /api/health OK"
|
||||
|
||||
- name: Uninstall and verify clean
|
||||
# Round-trip through scripts/uninstall.sh on real macOS. As a side
|
||||
# effect this exercises the macOS-only .app bundle + Launch Services
|
||||
# removal path (~/Applications/Unsloth Studio.app, lsregister -u)
|
||||
# which is not testable from a Linux runner. Skips gracefully if
|
||||
# scripts/uninstall.sh has not landed yet (lets this workflow merge
|
||||
# before #5497).
|
||||
run: |
|
||||
set -o pipefail
|
||||
if [ ! -f scripts/uninstall.sh ]; then
|
||||
echo "scripts/uninstall.sh not present in this tree; skipping round-trip"
|
||||
: > logs/uninstall.log
|
||||
exit 0
|
||||
fi
|
||||
sh scripts/uninstall.sh 2>&1 | tee logs/uninstall.log
|
||||
leak=0
|
||||
for p in \
|
||||
"$HOME/.unsloth/studio" \
|
||||
"$HOME/.local/share/unsloth" \
|
||||
"$HOME/Applications/Unsloth Studio.app" \
|
||||
"$HOME/Desktop/Unsloth Studio.app" \
|
||||
"$HOME/.local/bin/unsloth"; do
|
||||
if [ -e "$p" ] || [ -L "$p" ]; then
|
||||
echo "::error::leak: $p"
|
||||
leak=$((leak + 1))
|
||||
fi
|
||||
done
|
||||
[ "$leak" -eq 0 ] || exit 1
|
||||
sh scripts/uninstall.sh 2>&1 | tail -5
|
||||
sh scripts/uninstall.sh 2>&1 | tail -5
|
||||
echo "PASS: mac install -> update -> uninstall round-trip clean"
|
||||
|
||||
- name: Upload update logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: mac-studio-update-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/update.log
|
||||
logs/update2.log
|
||||
logs/studio.log
|
||||
logs/uninstall.log
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,128 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# PR-time smoke for the Tauri desktop wrapper. Builds the frontend and the
|
||||
# Tauri Linux debug binary, with no codesigning. Catches:
|
||||
# - tauri.conf.json drift
|
||||
# - src-tauri Cargo.toml or rust source breakage
|
||||
# - Tauri CLI version drift (we pin 2.10.1, matching release-desktop.yml)
|
||||
# - frontend output not picked up by Tauri's distDir
|
||||
#
|
||||
# Linux-only on a free `ubuntu-latest` runner. Mac and Windows desktop builds
|
||||
# stay in release-desktop.yml (manual `workflow_dispatch`) because they need
|
||||
# code-signing secrets and ~30 min of runner time each.
|
||||
|
||||
name: Studio Tauri CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/frontend/**'
|
||||
- 'studio/src-tauri/**'
|
||||
# CLI rename / signature change can break Tauri's spawned
|
||||
# `unsloth studio` -- include unsloth_cli in the trigger set.
|
||||
- 'unsloth_cli/**'
|
||||
- '.github/workflows/studio-tauri-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux-debug-build:
|
||||
name: Tauri Linux debug build (no codesign)
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Linux native deps for Tauri / WebKit2GTK
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev \
|
||||
librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
|
||||
|
||||
- uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
|
||||
with:
|
||||
workspaces: studio/src-tauri -> target
|
||||
|
||||
- name: Install pinned Tauri CLI (matches release-desktop.yml)
|
||||
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
|
||||
# required for `vite build`. The pre-install lockfile structural
|
||||
# audit (lockfile_supply_chain_audit.py) is the practical defence
|
||||
# against the npm postinstall-dropper class -- it fires BEFORE any
|
||||
# tarball runs, on the injection pattern itself rather than an
|
||||
# advisory-DB lookup.
|
||||
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1 --no-fund --no-audit
|
||||
|
||||
- name: Verify pinned Tauri CLI version
|
||||
run: |
|
||||
out="$(npx --prefix studio tauri --version)"
|
||||
echo "$out"
|
||||
[ "$out" = "tauri-cli 2.10.1" ] || { echo "::error::expected tauri-cli 2.10.1, got $out"; exit 1; }
|
||||
|
||||
- name: Lockfile supply-chain audit (pre-install scan)
|
||||
run: python3 scripts/lockfile_supply_chain_audit.py
|
||||
|
||||
- name: Frontend build (npm ci, vite)
|
||||
working-directory: studio/frontend
|
||||
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
|
||||
# required for `vite build`. The pre-install lockfile structural
|
||||
# audit (lockfile_supply_chain_audit.py) is the practical defence
|
||||
# against the npm postinstall-dropper class -- it fires BEFORE any
|
||||
# tarball runs, on the injection pattern itself rather than an
|
||||
# advisory-DB lookup.
|
||||
run: |
|
||||
npm ci --no-fund --no-audit
|
||||
npm run build
|
||||
test -f dist/index.html
|
||||
|
||||
- name: Tauri debug build (Linux, no bundle, no codesign)
|
||||
# `--debug` + `--no-bundle` keeps this lean: compiles the Rust crate,
|
||||
# confirms the frontend dist is wired into Tauri, but skips the AppImage
|
||||
# / .deb production. Code signing is irrelevant because we never produce
|
||||
# a distributable artifact.
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ''
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
|
||||
run: npx --prefix studio tauri build --debug --no-bundle
|
||||
|
||||
- name: Inspect produced binary
|
||||
run: |
|
||||
BIN=$(find studio/src-tauri/target/debug -maxdepth 1 -type f -executable 2>/dev/null \
|
||||
| grep -Ev '\.(d|so|dylib|dll)$' \
|
||||
| grep -Ev '/(deps|build|examples)$' \
|
||||
| head -1)
|
||||
echo "binary: $BIN"
|
||||
if [ -z "$BIN" ]; then
|
||||
echo "::error::Tauri debug binary not produced"
|
||||
ls -la studio/src-tauri/target/debug/ || true
|
||||
exit 1
|
||||
fi
|
||||
file "$BIN"
|
||||
du -h "$BIN"
|
||||
|
||||
- name: Upload Tauri debug build
|
||||
# Always upload so a green run leaves the binary inspectable too.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: tauri-debug-build
|
||||
path: |
|
||||
studio/src-tauri/target/debug
|
||||
studio/frontend/dist
|
||||
retention-days: 3
|
||||
@@ -0,0 +1,302 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# End-to-end Studio chat UI smoke via Playwright + Chromium against a
|
||||
# headless Linux runner. Boots Studio with the smallest GGUF
|
||||
# (gemma-3-270m-it UD-Q4_K_XL, ~254 MiB), drives the actual frontend
|
||||
# bundle, and asserts the full bootstrap-password / change-password /
|
||||
# send-message / persist-on-reload journey works end to end.
|
||||
#
|
||||
# This is the only workflow that catches regressions in the wiring
|
||||
# between the React frontend and the FastAPI backend, e.g. assistant-ui
|
||||
# version drift, /api/auth response shape changes, runtime-provider
|
||||
# regressions, or chat-history persistence breaking. Backend-only and
|
||||
# frontend-only CI happily pass while the actual user-visible UI is
|
||||
# broken (cf. the 2026.5.1 chat-history release).
|
||||
|
||||
name: Studio UI CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.sh'
|
||||
- 'pyproject.toml'
|
||||
# The Playwright test files themselves -- a PR that ONLY edits
|
||||
# the test must still trigger UI CI.
|
||||
- 'tests/studio/**'
|
||||
- '.github/workflows/studio-ui-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ui-smoke:
|
||||
name: Chat UI Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18892'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Linux deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev libssl-dev jq
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
|
||||
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
|
||||
|
||||
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
|
||||
if: always() && steps.prime-hf.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Install Playwright + Chromium
|
||||
run: |
|
||||
pip install 'playwright>=1.45'
|
||||
# --with-deps installs the OS-level runtime libs Chromium
|
||||
# needs (libnss3, libxkbcommon, etc.). About 30 s on a
|
||||
# warm runner.
|
||||
python -m playwright install --with-deps chromium
|
||||
|
||||
- name: Reset auth + boot Studio
|
||||
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
|
||||
# 180 s -- a cold runner with venv warm-up + lazy imports has
|
||||
# been seen to exceed 60 s. Failing the wait is more expensive
|
||||
# than waiting an extra two minutes.
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
|
||||
- name: Pass bootstrap password to the Playwright step
|
||||
# The Playwright test does its OWN /change-password through the
|
||||
# UI (Setup your account / Choose a new password), then loads
|
||||
# the model via page.evaluate against /api/inference/load with
|
||||
# the JWT it got from change-password. So the only thing we
|
||||
# have to hand it is the bootstrap password (so it can verify
|
||||
# post-rotation that the OLD bootstrap pw now returns 401).
|
||||
#
|
||||
# NEW + NEW2 are generated freshly per CI run via secrets.token_urlsafe
|
||||
# rather than hardcoded. If a workflow gets compromised, the
|
||||
# attacker can't replay a known-good rotated password against
|
||||
# any future / parallel Studio install -- the rotated value
|
||||
# only ever exists for the lifetime of this single job, masked
|
||||
# in the log via ::add-mask::.
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
NEW2="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "::add-mask::$NEW2"
|
||||
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive the chat UI with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18892
|
||||
# The test file lives in the repo so it can be run locally
|
||||
# against a freshly-installed Studio (BASE_URL=...; STUDIO_OLD_PW=
|
||||
# $(cat ~/.unsloth/studio/auth/.bootstrap_password); python ...).
|
||||
PW_ART_DIR: logs/playwright
|
||||
# Strict mode: in CI a missing button / nav / dialog must
|
||||
# FAIL the test. Locally the test still runs against partial
|
||||
# Studio installs without STUDIO_UI_STRICT.
|
||||
STUDIO_UI_STRICT: '1'
|
||||
run: |
|
||||
mkdir -p logs/playwright
|
||||
python tests/studio/playwright_chat_ui.py
|
||||
|
||||
- name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# The chat UI test ends by clicking the Shutdown menuitem, which
|
||||
# leaves the server dead. The extra UI test (Compare / Recipes /
|
||||
# Export / Studio / Settings) needs a fresh Studio, so we boot a
|
||||
# second one on a different port. Boot is fast (~3-5s on the
|
||||
# warm install we already did) so this adds little wall time.
|
||||
- name: Reset auth + boot Studio for extra UI tests (port 18894)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18894 \
|
||||
> logs/studio_extra.log 2>&1 &
|
||||
echo "STUDIO_EXTRA_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health on 18894
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:18894/api/health" > /tmp/health2.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health2.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health2.json
|
||||
|
||||
- name: Pass bootstrap pw for extra UI test
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIUiExtra-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18894
|
||||
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
|
||||
STUDIO_NEW_PW: ${{ env.STUDIO_EXTRA_NEW_PW }}
|
||||
PW_ART_DIR: logs/playwright_extra
|
||||
STUDIO_UI_STRICT: '1'
|
||||
GGUF_REPO: ${{ env.GGUF_REPO }}
|
||||
GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
|
||||
run: |
|
||||
mkdir -p logs/playwright_extra
|
||||
python tests/studio/playwright_extra_ui.py
|
||||
|
||||
- name: Stop second Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# IME + multilingual paste regression (issue #5318 / PR #5327).
|
||||
# Third Studio on its own port so a hang here cannot poison the
|
||||
# earlier UI tests. No GGUF -- the bug surface is the composer.
|
||||
- name: Reset auth + boot Studio for IME / i18n tests (port 18896)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \
|
||||
> logs/studio_ime.log 2>&1 &
|
||||
echo "STUDIO_IME_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health on 18896
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:18896/api/health" > /tmp/health3.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health3.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health3.json
|
||||
|
||||
- name: Pass bootstrap pw for IME / i18n test
|
||||
# IME smoke does the change-password against the bootstrap that
|
||||
# Studio's frontend injects into the page, so it only needs the
|
||||
# NEW password.
|
||||
run: |
|
||||
NEW="CIIme-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "STUDIO_IME_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive IME + multilingual paste regression with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18896
|
||||
STUDIO_NEW_PW: ${{ env.STUDIO_IME_NEW_PW }}
|
||||
PW_ART_DIR: logs/playwright_ime
|
||||
STUDIO_UI_STRICT: '1'
|
||||
run: |
|
||||
mkdir -p logs/playwright_ime
|
||||
python tests/studio/playwright_chat_ime_i18n.py
|
||||
|
||||
- name: Stop third Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_IME_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
# Capture backend + llama-server logs (all three Studios share this
|
||||
# dir) so a stray 500 has a server-side traceback.
|
||||
mkdir -p logs/server-logs
|
||||
cp -r ~/.unsloth/studio/logs/. logs/server-logs/ 2>/dev/null || true
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
# Always upload so a green run's screenshots stay reviewable --
|
||||
# catches "passed but the UI is silently broken" regressions.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-ui-smoke-artifacts
|
||||
path: |
|
||||
logs/studio.log
|
||||
logs/studio_extra.log
|
||||
logs/studio_ime.log
|
||||
logs/install.log
|
||||
logs/server-logs/
|
||||
logs/playwright
|
||||
logs/playwright_extra
|
||||
logs/playwright_ime
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,197 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Verifies that `unsloth studio update --local` is idempotent: a fresh
|
||||
# install via install.sh, followed by `unsloth studio update --local`,
|
||||
# succeeds and is a no-op for the llama.cpp prebuilt (it should report
|
||||
# "prebuilt up to date and validated", not re-run the source build).
|
||||
#
|
||||
# This catches regressions in setup.sh's update path that the existing
|
||||
# GGUF / wheel jobs would miss because they only invoke install.sh once.
|
||||
|
||||
name: Studio Update CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'install.sh'
|
||||
- 'scripts/uninstall.sh'
|
||||
- 'studio/setup.sh'
|
||||
- 'studio/install_python_stack.py'
|
||||
- 'studio/install_llama_prebuilt.py'
|
||||
- 'studio/backend/requirements/**'
|
||||
- 'unsloth_cli/commands/studio.py'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/studio-update-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-idempotency:
|
||||
name: Studio Updating Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Linux deps 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
# Don't cache pip: this job runs `bash install.sh` and
|
||||
# `unsloth studio update --local` which both go through
|
||||
# `uv` and never populate ~/.cache/pip. setup-python's
|
||||
# post-step then fatal-errors with "Cache folder path is
|
||||
# retrieved for pip but doesn't exist on disk".
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
# Pass the workflow token so the llama.cpp prebuilt installer's
|
||||
# GitHub-API call to list releases isn't rate-limited (60/hr
|
||||
# unauthenticated). Without this, three consecutive install +
|
||||
# update + update calls in this job exceed the limit and the
|
||||
# prebuilt path falls back to source build.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: First update should be a no-op (prebuilt already validated)
|
||||
# `unsloth studio update --local` runs studio/setup.sh against
|
||||
# the local repo. Right after install.sh the llama.cpp prebuilt
|
||||
# has just been installed and validated, so the second run must
|
||||
# take the "prebuilt up to date and validated" code path. Any
|
||||
# source-build fallback or re-download here means setup.sh's
|
||||
# idempotency regressed.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update.log
|
||||
if grep -q "falling back to source build" logs/update.log; then
|
||||
echo "::error::studio update fell back to source-build llama.cpp on a fresh install. setup.sh idempotency regressed."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update.log; then
|
||||
echo "::error::no prebuilt up-to-date marker in update.log. Did setup.sh skip the prebuilt path on update?"
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
echo "update path took the prebuilt fast path"
|
||||
|
||||
- name: Second update must also be a no-op
|
||||
# Two consecutive `update`s back-to-back is the usual desktop
|
||||
# flow (auto-update, then user-triggered update). Asserting the
|
||||
# second run is also clean rules out hidden state changes from
|
||||
# the first one.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update2.log
|
||||
grep -q "falling back to source build" logs/update2.log && {
|
||||
echo "::error::second update fell back to source build"
|
||||
tail -60 logs/update2.log; exit 1; } || true
|
||||
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
|
||||
echo "second update was clean"
|
||||
|
||||
- name: Boot Studio briefly to confirm the install is still usable
|
||||
# If `update --local` accidentally broke the venv or wiped the
|
||||
# llama-server binary, the server would fail to start here.
|
||||
run: |
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \
|
||||
> logs/studio.log 2>&1 &
|
||||
PID=$!
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fs http://127.0.0.1:18891/api/health > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if ! jq -e '.status == "healthy"' /tmp/health.json 2>/dev/null; then
|
||||
echo "Studio failed to come up after `update`"
|
||||
tail -200 logs/studio.log
|
||||
kill "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
kill "$PID" 2>/dev/null || true
|
||||
echo "post-update Studio /api/health OK"
|
||||
|
||||
- name: Uninstall and verify clean
|
||||
# Round-trip the installer through scripts/uninstall.sh: confirms the
|
||||
# uninstaller actually finds and removes everything install.sh +
|
||||
# update wrote. Safety-guard scenarios (refuse-$HOME etc.) belong
|
||||
# in a separate fast smoke job; this is the happy-path cleanup
|
||||
# assertion that catches regressions where install.sh starts
|
||||
# writing to a new location and scripts/uninstall.sh hasn't caught up.
|
||||
# Skips gracefully if scripts/uninstall.sh has not landed yet (lets
|
||||
# this workflow merge before #5497).
|
||||
run: |
|
||||
set -o pipefail
|
||||
if [ ! -f scripts/uninstall.sh ]; then
|
||||
echo "scripts/uninstall.sh not present in this tree; skipping round-trip"
|
||||
: > logs/uninstall.log
|
||||
exit 0
|
||||
fi
|
||||
sh scripts/uninstall.sh 2>&1 | tee logs/uninstall.log
|
||||
leak=0
|
||||
for p in \
|
||||
"$HOME/.unsloth/studio" \
|
||||
"$HOME/.local/share/unsloth" \
|
||||
"$HOME/Desktop/Unsloth Studio.desktop" \
|
||||
"$HOME/.local/bin/unsloth"; do
|
||||
if [ -e "$p" ] || [ -L "$p" ]; then
|
||||
echo "::error::leak: $p"
|
||||
ls -la "$p" 2>&1 | head -3
|
||||
leak=$((leak + 1))
|
||||
fi
|
||||
done
|
||||
[ "$leak" -eq 0 ] || exit 1
|
||||
# Idempotent: re-runs exit 0 on an empty $HOME.
|
||||
sh scripts/uninstall.sh 2>&1 | tail -5
|
||||
sh scripts/uninstall.sh 2>&1 | tail -5
|
||||
echo "PASS: install -> update -> uninstall round-trip clean"
|
||||
|
||||
- name: Upload update logs
|
||||
# Always upload so a green run still leaves the install + two
|
||||
# update logs + uninstall log reviewable.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-update-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/update.log
|
||||
logs/update2.log
|
||||
logs/studio.log
|
||||
logs/uninstall.log
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,236 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Windows counterpart to studio-api-smoke.yml / studio-mac-api-smoke.yml.
|
||||
# Same tests/studio/studio_api_smoke.py exercise (CORS hardening, auth
|
||||
# state machine, JWT expiry, API key lifecycle, /v1/models /
|
||||
# /v1/embeddings / /v1/responses, endpoint-by-endpoint auth audit) but
|
||||
# on the FREE windows-latest runner. The file-mode hardening section
|
||||
# (Section 6) is Linux-only and short-circuits on non-POSIX; the rest
|
||||
# is platform-portable.
|
||||
|
||||
name: Windows Studio API CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.ps1'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio/**'
|
||||
- '.github/workflows/studio-windows-api-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api-smoke:
|
||||
name: Studio API & Auth Tests
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18895'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
|
||||
# download prints a "✓" checkmark and crashes otherwise).
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
|
||||
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
|
||||
|
||||
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
|
||||
if: always() && steps.prime-hf.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
|
||||
shell: pwsh
|
||||
# See studio-windows-update-smoke.yml for the full rationale.
|
||||
# tl;dr: setup.ps1 needs npm >=11 to skip a 35 s winget Node
|
||||
# reinstall, and Defender's real-time scan dominates the
|
||||
# frontend / uv-pip-extract steps.
|
||||
run: |
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Write-Host "npm version before upgrade: $(npm -v)"
|
||||
npm install -g 'npm@^11' 2>&1 | Out-Host
|
||||
Write-Host "npm version after upgrade: $(npm -v)"
|
||||
# NOTE: do NOT pre-create these directories. See
|
||||
# studio-windows-update-smoke.yml for the full rationale --
|
||||
# creating an empty studio/frontend/dist trips setup.ps1's
|
||||
# mtime-based staleness check into "frontend up to date, skip
|
||||
# rebuild" and Studio boots with an empty dist directory.
|
||||
# Add-MpPreference accepts paths that do not yet exist.
|
||||
foreach ($p in @(
|
||||
"$env:USERPROFILE\.unsloth",
|
||||
"$env:USERPROFILE\AppData\Local\uv",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\node_modules",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\dist"
|
||||
)) {
|
||||
try {
|
||||
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
|
||||
Write-Host "Defender exclusion added: $p"
|
||||
} catch {
|
||||
Write-Host "Defender exclusion skipped ($($_.Exception.Message)): $p"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
# plain 2>&1 does not. setup.ps1 emits "prebuilt installed
|
||||
# and validated" via Write-Host, and we grep for that.
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
|
||||
|
||||
- name: Assert install.ps1 used the Windows llama.cpp prebuilt
|
||||
run: |
|
||||
# Filesystem-based check (setup.ps1's stream output isn't
|
||||
# captured back through this parent step's pipeline; see
|
||||
# studio-windows-ui-smoke.yml for full explanation).
|
||||
LLAMA_DIR=~/.unsloth/llama.cpp
|
||||
INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json"
|
||||
BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe"
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.ps1 fell back to source-build llama.cpp on Windows."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$INFO" ]; then
|
||||
echo "::error::no UNSLOTH_PREBUILT_INFO.json at $INFO."
|
||||
ls -la "$LLAMA_DIR" || true
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "::error::no llama-server.exe at $BIN."
|
||||
ls -la "$LLAMA_DIR/build/bin" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
# install.ps1's User-PATH update doesn't propagate to a
|
||||
# running Git Bash session; export the shim dir so the
|
||||
# next `unsloth ...` invocation finds it.
|
||||
run: |
|
||||
SHIM_DIR=~/.unsloth/studio/bin
|
||||
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
||||
echo "::error::unsloth.exe shim not found at $SHIM_DIR"
|
||||
ls -la ~/.unsloth/studio/ || true
|
||||
exit 1
|
||||
fi
|
||||
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install pyjwt for the JWT-expiry forge test
|
||||
run: python -m pip install 'pyjwt>=2.6'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
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 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
|
||||
- name: Pass bootstrap password + rotated targets to the test
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
NEW2="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "::add-mask::$NEW2"
|
||||
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Studio API & Auth tests
|
||||
# Do NOT pin STUDIO_AUTH_DIR here. The Mac/Linux mirrors
|
||||
# hardcode runner-specific paths (/Users/runner/...,
|
||||
# /home/runner/...), but on Windows the path is
|
||||
# C:\Users\runneradmin\.unsloth\studio\auth and varies by
|
||||
# runner image. studio_api_smoke.py defaults to
|
||||
# Path.home()/".unsloth"/"studio"/"auth" when the env is
|
||||
# unset, which is correct on every OS.
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18895
|
||||
run: python tests/studio/studio_api_smoke.py
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Upload API smoke logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: windows-studio-api-smoke-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/studio.log
|
||||
retention-days: 7
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,406 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Windows counterpart to studio-ui-smoke.yml / studio-mac-ui-smoke.yml.
|
||||
# Same Playwright + Chromium end-to-end chat UI flow + extra UI flow,
|
||||
# but on the FREE windows-latest runner so we catch Windows-specific
|
||||
# regressions in the install path (install.ps1), the Studio CLI's
|
||||
# Windows process-management branches, and the llama.cpp prebuilt's
|
||||
# Windows HTTP layer.
|
||||
|
||||
name: Windows Studio UI CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.ps1'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio/**'
|
||||
- '.github/workflows/studio-windows-ui-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ui-smoke:
|
||||
name: Chat UI Tests
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
# Default every step's shell to Git Bash. windows-latest's default
|
||||
# shell is pwsh; without this each curl / heredoc / `kill $PID`
|
||||
# step would need its own `shell: bash`. Steps that genuinely
|
||||
# need PowerShell (install.ps1 invocation) override per-step.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18896'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
# Force UTF-8 for stdio so Python tools (hf download, Studio
|
||||
# CLI, etc.) can print Unicode characters like the success
|
||||
# checkmark "✓". Windows defaults to cp1252 / charmap and
|
||||
# any tool that prints "OK ✓" hits a UnicodeEncodeError.
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
# No `cache: 'npm'`. setup-node's npm cache restore silently
|
||||
# aborts the entire job on Windows runners when the npm cache
|
||||
# path (`C:\npm\cache` per `npm config get cache`) doesn't yet
|
||||
# exist on a fresh runner -- the step exits without an error
|
||||
# message and every following step gets skipped. See
|
||||
# npm/cli#7308. The frontend `npm ci` is fast enough without
|
||||
# the cache that the reliability gain is worth the ~30s.
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
# No `cache: 'pip'`. install.ps1 / setup.ps1 use uv and
|
||||
# never populate ~/.cache/pip; setup-python's post-step
|
||||
# then fatal-errors with "Cache folder path is retrieved
|
||||
# for pip but doesn't exist on disk".
|
||||
|
||||
- name: Restore HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
bash .github/scripts/hf-download-with-retry.sh "$GGUF_REPO" "$GGUF_FILE"
|
||||
bash .github/scripts/hf-download-with-retry.sh ggml-org/models tinyllamas/stories260K.gguf
|
||||
|
||||
- name: Save HF_HOME for ${{ env.GGUF_REPO }}
|
||||
if: always() && steps.prime-hf.outcome == 'success'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: hf-cache
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
|
||||
|
||||
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
|
||||
shell: pwsh
|
||||
# See studio-windows-update-smoke.yml for the full rationale.
|
||||
# tl;dr: setup.ps1 needs npm >=11 to skip a 35 s winget Node
|
||||
# reinstall, and Defender's real-time scan dominates the
|
||||
# frontend / uv-pip-extract steps.
|
||||
run: |
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Write-Host "npm version before upgrade: $(npm -v)"
|
||||
npm install -g 'npm@^11' 2>&1 | Out-Host
|
||||
Write-Host "npm version after upgrade: $(npm -v)"
|
||||
# NOTE: do NOT pre-create these directories. See
|
||||
# studio-windows-update-smoke.yml for the full rationale --
|
||||
# creating an empty studio/frontend/dist trips setup.ps1's
|
||||
# mtime-based staleness check into "frontend up to date, skip
|
||||
# rebuild" and Studio boots with an empty dist directory.
|
||||
# Add-MpPreference accepts paths that do not yet exist.
|
||||
foreach ($p in @(
|
||||
"$env:USERPROFILE\.unsloth",
|
||||
"$env:USERPROFILE\AppData\Local\uv",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\node_modules",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\dist"
|
||||
)) {
|
||||
try {
|
||||
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
|
||||
Write-Host "Defender exclusion added: $p"
|
||||
} catch {
|
||||
Write-Host "Defender exclusion skipped ($($_.Exception.Message)): $p"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Seed a legacy launch-studio.vbs (upgrade-cleanup check)
|
||||
# Simulate a pre-hardening install so the post-install assertion below
|
||||
# proves the installer DELETES an existing launch-studio.vbs (the exact
|
||||
# Kaspersky-flagged file), not merely stops generating it.
|
||||
shell: pwsh
|
||||
run: |
|
||||
$appDir = Join-Path $env:LOCALAPPDATA 'Unsloth Studio'
|
||||
New-Item -ItemType Directory -Force -Path $appDir | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $appDir 'launch-studio.vbs') -Value 'WScript.Echo "legacy"' -Encoding Unicode
|
||||
Write-Host "seeded legacy launch-studio.vbs at $appDir"
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
# install.ps1 is the supported Windows installer. install.sh
|
||||
# has no Windows branch (apt-get / brew calls). The PS1
|
||||
# script's `Install-UnslothStudio @args` line at the bottom
|
||||
# forwards `--local --no-torch` correctly.
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 redirects ALL PowerShell streams (stdout, stderr,
|
||||
# warning, verbose, debug, information) into the success
|
||||
# stream so Tee-Object captures everything. install.ps1
|
||||
# and setup.ps1 emit step/substep markers via Write-Host
|
||||
# which lands on the Information stream (PS 5+); without
|
||||
# the wildcard redirect, those markers (including
|
||||
# "prebuilt installed and validated") never reach
|
||||
# logs/install.log and the post-step grep asserter fails.
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
|
||||
|
||||
- name: Assert install.ps1 used the Windows llama.cpp prebuilt
|
||||
run: |
|
||||
# install.ps1's setup.ps1 child writes "prebuilt installed
|
||||
# and validated" to its own console host -- that output
|
||||
# does NOT come back through this parent step's stdout
|
||||
# pipeline (no matter how aggressively we redirect: *>&1,
|
||||
# tee, etc.). Verify the install via the filesystem
|
||||
# instead. setup.ps1 writes UNSLOTH_PREBUILT_INFO.json
|
||||
# next to the install dir on success, and lays the
|
||||
# binaries under build/bin/Release/ on Windows.
|
||||
STUDIO_HOME=~/.unsloth/studio
|
||||
LLAMA_DIR=~/.unsloth/llama.cpp
|
||||
INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json"
|
||||
BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe"
|
||||
# Source-build fallback grep stays as a fast bail-out.
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.ps1 fell back to source-build llama.cpp on Windows."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$INFO" ]; then
|
||||
echo "::error::no UNSLOTH_PREBUILT_INFO.json at $INFO; setup.ps1 didn't install the prebuilt."
|
||||
ls -la "$LLAMA_DIR" || true
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "::error::no llama-server.exe at $BIN; prebuilt extraction incomplete."
|
||||
ls -la "$LLAMA_DIR/build/bin" || true
|
||||
ls -la "$LLAMA_DIR/build/bin/Release" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Assert Studio launcher chain (no VBS, hidden PowerShell shortcut)
|
||||
# The shortcut launch path is otherwise untested here (the steps below
|
||||
# boot `unsloth studio` directly). Guard against re-introducing the VBS
|
||||
# that tripped Kaspersky HEUR:Trojan.VBS.Agent.gen and against the .lnk
|
||||
# pointing anywhere other than hidden PowerShell over launch-studio.ps1.
|
||||
shell: pwsh
|
||||
run: |
|
||||
$appDir = Join-Path $env:LOCALAPPDATA 'Unsloth Studio'
|
||||
if (Test-Path -LiteralPath (Join-Path $appDir 'launch-studio.vbs')) {
|
||||
throw "regression: launch-studio.vbs exists (the Kaspersky VBS-FP shape)"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $appDir 'launch-studio.ps1'))) {
|
||||
throw "missing launch-studio.ps1 in $appDir"
|
||||
}
|
||||
$lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Unsloth Studio.lnk'
|
||||
if (-not (Test-Path -LiteralPath $lnk)) {
|
||||
$lnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk'
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $lnk)) { throw "no Unsloth Studio.lnk on Desktop or Start Menu" }
|
||||
$sc = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk)
|
||||
Write-Host "shortcut target: $($sc.TargetPath)"
|
||||
Write-Host "shortcut args: $($sc.Arguments)"
|
||||
if ($sc.TargetPath -match 'wscript\.exe$') { throw "shortcut still targets wscript.exe (VBS host)" }
|
||||
if ($sc.TargetPath -notmatch 'powershell\.exe$') { throw "unexpected shortcut target: $($sc.TargetPath)" }
|
||||
if ($sc.Arguments -notmatch '-WindowStyle Hidden') {
|
||||
throw "shortcut must launch windowless (-WindowStyle Hidden)"
|
||||
}
|
||||
Write-Host "launcher chain OK (no VBS; hidden powershell over launch-studio.ps1)"
|
||||
|
||||
- name: Launch Studio via the shortcut and assert health
|
||||
# Run the exact command the .lnk stores (hidden PowerShell over
|
||||
# launch-studio.ps1) and confirm it brings the backend up. This is the
|
||||
# only step that proves the shortcut launch is not silently broken.
|
||||
# Default port range is 8888-8908; the later UI tests use 18896/18897, so
|
||||
# there is no conflict, and we tear this server down before they boot.
|
||||
shell: pwsh
|
||||
run: |
|
||||
$lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Unsloth Studio.lnk'
|
||||
if (-not (Test-Path -LiteralPath $lnk)) {
|
||||
$lnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk'
|
||||
}
|
||||
$sc = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk)
|
||||
Write-Host "launching: $($sc.TargetPath) $($sc.Arguments)"
|
||||
Start-Process -FilePath $sc.TargetPath -ArgumentList $sc.Arguments -WorkingDirectory $sc.WorkingDirectory
|
||||
$foundPort = 0
|
||||
foreach ($i in 1..180) {
|
||||
foreach ($port in 8888..8908) {
|
||||
try {
|
||||
$r = Invoke-RestMethod -Uri "http://127.0.0.1:$port/api/health" -TimeoutSec 1
|
||||
if ($r.status -eq 'healthy' -and $r.service -eq 'Unsloth UI Backend') { $foundPort = $port; break }
|
||||
} catch {}
|
||||
}
|
||||
if ($foundPort) { break }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
# Tear down the shortcut-launched server before the main UI tests boot.
|
||||
try {
|
||||
$owner = (Get-NetTCPConnection -LocalPort $foundPort -State Listen -ErrorAction Stop | Select-Object -First 1).OwningProcess
|
||||
if ($owner) { taskkill /PID $owner /T /F 2>$null | Out-Null }
|
||||
} catch {}
|
||||
if (-not $foundPort) { throw "Studio did not become healthy when launched via the shortcut" }
|
||||
Write-Host "Studio healthy on port $foundPort (launched via the shortcut)"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
# install.ps1 puts unsloth.exe at $StudioHome\bin\unsloth.exe
|
||||
# and adds that dir to the User PATH via the Windows registry.
|
||||
# Registry-level PATH updates don't propagate to a running
|
||||
# Git Bash session, so the next step's `unsloth ...` invocation
|
||||
# would hit "command not found". Re-export the shim dir to
|
||||
# GITHUB_PATH so every subsequent step in this job sees it.
|
||||
run: |
|
||||
SHIM_DIR=~/.unsloth/studio/bin
|
||||
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
||||
echo "::error::unsloth.exe shim not found at $SHIM_DIR"
|
||||
ls -la ~/.unsloth/studio/ || true
|
||||
exit 1
|
||||
fi
|
||||
# GITHUB_PATH wants Windows-style paths; convert via cygpath.
|
||||
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
||||
echo "Added Studio shim dir to PATH: $(cygpath -w "$SHIM_DIR")"
|
||||
|
||||
- name: Install Playwright + Chromium
|
||||
# No --with-deps on Windows: that flag installs Linux apt
|
||||
# packages. windows-latest ships the system frameworks
|
||||
# Chromium needs (Edge / WebView2) already.
|
||||
run: |
|
||||
python -m pip install 'playwright>=1.45'
|
||||
python -m playwright install chromium
|
||||
|
||||
- name: Reset auth + boot Studio
|
||||
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 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
|
||||
- name: Pass bootstrap password to the Playwright step
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
NEW2="CIUi-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "::add-mask::$NEW2"
|
||||
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive the chat UI with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18896
|
||||
PW_ART_DIR: logs/playwright
|
||||
STUDIO_UI_STRICT: '1'
|
||||
# windows-latest free runner is 4 vCPU / 16 GB; gemma-3-
|
||||
# 270m turn latency under llama-server's CPU backend can
|
||||
# crowd the 180s default (slower than ubuntu-latest on
|
||||
# the same model). Keep the same generous budget the Mac
|
||||
# job uses.
|
||||
STUDIO_UI_TURN_TIMEOUT_MS: '540000'
|
||||
run: |
|
||||
mkdir -p logs/playwright
|
||||
python tests/studio/playwright_chat_ui.py
|
||||
|
||||
- name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Reset auth + boot Studio for extra UI tests (port 18897)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
|
||||
> logs/studio_extra.log 2>&1 &
|
||||
echo "STUDIO_EXTRA_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health on 18897
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:18897/api/health" > /tmp/health2.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health2.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health2.json
|
||||
|
||||
- name: Pass bootstrap pw for extra UI test
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CIUiExtra-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18897
|
||||
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
|
||||
STUDIO_NEW_PW: ${{ env.STUDIO_EXTRA_NEW_PW }}
|
||||
PW_ART_DIR: logs/playwright_extra
|
||||
STUDIO_UI_STRICT: '1'
|
||||
STUDIO_UI_TURN_TIMEOUT_MS: '540000'
|
||||
GGUF_REPO: ${{ env.GGUF_REPO }}
|
||||
GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
|
||||
run: |
|
||||
mkdir -p logs/playwright_extra
|
||||
python tests/studio/playwright_extra_ui.py
|
||||
|
||||
- name: Stop second Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: windows-studio-ui-smoke-artifacts
|
||||
path: |
|
||||
logs/studio.log
|
||||
logs/studio_extra.log
|
||||
logs/install.log
|
||||
logs/playwright
|
||||
logs/playwright_extra
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,295 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Windows counterpart to studio-update-smoke.yml /
|
||||
# studio-mac-update-smoke.yml. Verifies that on the FREE
|
||||
# windows-latest runner:
|
||||
#
|
||||
# 1. install.ps1 --local --no-torch installs Studio AND auto-fetches
|
||||
# the prebuilt llama.cpp Windows binary (app-<tag>-windows-x64-cpu
|
||||
# from unslothai/llama.cpp). Hitting the source-build fallback is
|
||||
# treated as an Unsloth bug -- Studio must always pick the
|
||||
# prebuilt on Windows.
|
||||
# 2. unsloth studio update --local is idempotent. Two consecutive
|
||||
# runs both report "prebuilt up to date and validated", no
|
||||
# source-build fallback. The CLI's _find_setup_script picks
|
||||
# setup.ps1 on Windows automatically.
|
||||
# 3. The installed Studio still boots and /api/health returns
|
||||
# healthy after the update path.
|
||||
|
||||
name: Windows Studio Update CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'install.ps1'
|
||||
- 'scripts/uninstall.ps1'
|
||||
- 'studio/setup.ps1'
|
||||
- 'studio/setup.bat'
|
||||
- 'studio/install_python_stack.py'
|
||||
- 'studio/install_llama_prebuilt.py'
|
||||
- 'studio/backend/requirements/**'
|
||||
- 'unsloth_cli/commands/studio.py'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/studio-windows-update-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-idempotency:
|
||||
name: Studio Updating Tests
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
|
||||
# download / Studio CLI print "✓" checkmarks and crash
|
||||
# otherwise).
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
# Don't cache pip: install.ps1 + setup.ps1 go through uv
|
||||
# and never populate ~/.cache/pip; setup-python's post-step
|
||||
# then fatal-errors with "Cache folder path is retrieved
|
||||
# for pip but doesn't exist on disk".
|
||||
|
||||
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
|
||||
shell: pwsh
|
||||
# Two surgical fixes against measured Windows-only install
|
||||
# waste (vs Mac/Linux on the same SHA):
|
||||
#
|
||||
# (1) npm. setup.ps1's Get-NodeDecision requires Node 22.12+
|
||||
# (or 20.19+ / 23+) AND npm >=11 because Vite 8 needs both.
|
||||
# actions/setup-node@v4 with `node-version: '22'` lands
|
||||
# Node 22.22.2 + the npm 10.9.7 it bundles, so the decision
|
||||
# is "bundled" and setup.ps1 downloads an isolated Node (~30
|
||||
# MB) we don't need on a runner that already has a fine Node.
|
||||
# `npm install -g npm@^11` updates the runner's npm in-place
|
||||
# in ~5 s, flipping the decision to "system" so setup.ps1
|
||||
# reuses the existing Node with no download.
|
||||
#
|
||||
# (2) Defender. windows-latest's real-time scan opens / hashes
|
||||
# every file Studio writes during install (Vite output =
|
||||
# thousands of small chunks, uv pip = wheel-extraction =
|
||||
# thousands of small files). The latency dominates the
|
||||
# 200 s frontend build and the 90 s deps install. Adding
|
||||
# ExclusionPath entries for the directories the install
|
||||
# writes to drops per-file open latency from ~ms to ~us.
|
||||
# Add-MpPreference needs admin; the runneradmin user has
|
||||
# it, but wrap in try/catch so a permission flake leaves
|
||||
# the install otherwise unaffected.
|
||||
run: |
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Write-Host "npm version before upgrade: $(npm -v)"
|
||||
npm install -g 'npm@^11' 2>&1 | Out-Host
|
||||
Write-Host "npm version after upgrade: $(npm -v)"
|
||||
# NOTE: do NOT pre-create these directories before adding the
|
||||
# exclusion -- creating an empty studio/frontend/dist trips
|
||||
# setup.ps1 line 1281-1296's mtime-based "is the frontend
|
||||
# stale?" check into "up to date, skip rebuild", because the
|
||||
# newly-created dist's mtime is younger than every source
|
||||
# file. Studio then boots with an empty dist and 500s on
|
||||
# GET / with FileNotFoundError: dist\index.html. See run
|
||||
# 25546676715 / job 74984469728.
|
||||
# Add-MpPreference accepts paths that do not yet exist; the
|
||||
# exclusion is registered and applies when the path
|
||||
# materialises.
|
||||
foreach ($p in @(
|
||||
"$env:USERPROFILE\.unsloth",
|
||||
"$env:USERPROFILE\AppData\Local\uv",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\node_modules",
|
||||
"$env:GITHUB_WORKSPACE\studio\frontend\dist"
|
||||
)) {
|
||||
try {
|
||||
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
|
||||
Write-Host "Defender exclusion added: $p"
|
||||
} catch {
|
||||
Write-Host "Defender exclusion skipped ($($_.Exception.Message)): $p"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# *>&1 captures Write-Host (Information stream) output;
|
||||
# plain 2>&1 does not. setup.ps1 emits "prebuilt installed
|
||||
# and validated" via Write-Host, and we grep for that.
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
|
||||
|
||||
- name: Assert install.ps1 used the Windows llama.cpp prebuilt
|
||||
run: |
|
||||
# Filesystem-based check (setup.ps1's stream output isn't
|
||||
# captured back through the parent pipeline).
|
||||
LLAMA_DIR=~/.unsloth/llama.cpp
|
||||
INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json"
|
||||
BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe"
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.ps1 fell back to source-build llama.cpp on Windows."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$INFO" ]; then
|
||||
echo "::error::no UNSLOTH_PREBUILT_INFO.json at $INFO."
|
||||
ls -la "$LLAMA_DIR" || true
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$BIN" ]; then
|
||||
echo "::error::no llama-server.exe at $BIN."
|
||||
ls -la "$LLAMA_DIR/build/bin" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
run: |
|
||||
SHIM_DIR=~/.unsloth/studio/bin
|
||||
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
||||
echo "::error::unsloth.exe shim not found at $SHIM_DIR"
|
||||
ls -la ~/.unsloth/studio/ || true
|
||||
exit 1
|
||||
fi
|
||||
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
||||
|
||||
- name: First update should be a no-op (prebuilt already validated)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update.log
|
||||
if grep -q "falling back to source build" logs/update.log; then
|
||||
echo "::error::studio update fell back to source-build llama.cpp on Windows."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update.log; then
|
||||
echo "::error::no prebuilt up-to-date marker in update.log."
|
||||
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
||||
exit 1
|
||||
fi
|
||||
echo "update path took the prebuilt fast path"
|
||||
|
||||
- name: Second update must also be a no-op
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
||||
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
unsloth studio update --local 2>&1 | tee logs/update2.log
|
||||
grep -q "falling back to source build" logs/update2.log && {
|
||||
echo "::error::second update fell back to source build on Windows"
|
||||
tail -60 logs/update2.log; exit 1; } || true
|
||||
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
|
||||
echo "second update was clean"
|
||||
|
||||
- name: Boot Studio briefly to confirm the install is still usable
|
||||
run: |
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \
|
||||
> logs/studio.log 2>&1 &
|
||||
PID=$!
|
||||
HEALTHY=""
|
||||
# Use jq (a Git Bash builtin) instead of `python -c
|
||||
# open('/tmp/health.json')` to read the saved health
|
||||
# response. Bash on windows-latest is MSYS Git Bash, which
|
||||
# resolves `/tmp/...` against the MSYS root, while the
|
||||
# python interpreter is Windows-native and resolves it
|
||||
# against the current drive's root. The two paths don't
|
||||
# agree, so python never finds the file curl just wrote.
|
||||
# jq reads through MSYS, so the path matches. Mirrors what
|
||||
# studio-windows-api-smoke.yml and the other Windows smoke
|
||||
# workflows already do.
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fs http://127.0.0.1:18891/api/health > /tmp/health.json; then
|
||||
if jq -e '.status == "healthy"' /tmp/health.json >/dev/null; then
|
||||
HEALTHY=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z "$HEALTHY" ]; then
|
||||
echo "Studio failed to come up after \`update\`"
|
||||
tail -200 logs/studio.log
|
||||
kill "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
kill "$PID" 2>/dev/null || true
|
||||
echo "post-update Studio /api/health OK"
|
||||
|
||||
- name: Uninstall and verify clean
|
||||
# Round-trip through scripts/uninstall.ps1 against the default
|
||||
# install tree at %USERPROFILE%\.unsloth\studio. Catches
|
||||
# regressions where install.ps1 starts writing under a new key
|
||||
# (registry, Start Menu, %APPDATA%) and scripts/uninstall.ps1 has
|
||||
# not been updated to match. Skips gracefully if
|
||||
# scripts/uninstall.ps1 has not landed yet (lets this workflow
|
||||
# merge before #5513).
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
if (-not (Test-Path "$PWD\scripts\uninstall.ps1")) {
|
||||
Write-Host "scripts/uninstall.ps1 not present in this tree; skipping round-trip"
|
||||
"" | Set-Content logs/uninstall.log
|
||||
exit 0
|
||||
}
|
||||
pwsh -NoProfile -File "$PWD\scripts\uninstall.ps1" *>&1 | Tee-Object -FilePath logs/uninstall.log
|
||||
$leak = 0
|
||||
foreach ($p in @(
|
||||
"$env:USERPROFILE\.unsloth\studio",
|
||||
"$env:USERPROFILE\.unsloth\studio\unsloth_studio",
|
||||
"$env:USERPROFILE\.unsloth\studio\bin\unsloth.exe"
|
||||
)) {
|
||||
if (Test-Path -LiteralPath $p) {
|
||||
Write-Host "::error::leak: $p"
|
||||
$leak++
|
||||
}
|
||||
}
|
||||
if ($leak -gt 0) { exit 1 }
|
||||
# Idempotency.
|
||||
pwsh -NoProfile -File "$PWD\scripts\uninstall.ps1" *>&1 | Select-Object -Last 5
|
||||
pwsh -NoProfile -File "$PWD\scripts\uninstall.ps1" *>&1 | Select-Object -Last 5
|
||||
Write-Host "PASS: windows install -> update -> uninstall round-trip clean"
|
||||
|
||||
- name: Upload update logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: windows-studio-update-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/update.log
|
||||
logs/update2.log
|
||||
logs/studio.log
|
||||
logs/uninstall.log
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,398 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Cross-version compat canary for the four upstream packages whose
|
||||
# release cadence regularly breaks unsloth + unsloth-zoo:
|
||||
#
|
||||
# 1. vLLM (LoRA worker manager, BnB loader, cumem allocator)
|
||||
# 2. TRL / GRPO (trainer source rewriters in unsloth.models.rl*)
|
||||
# 3. PEFT (LoraConfig, get_peft_model, LoraLayer, bnb integration)
|
||||
# 4. sentence-transformers (Transformer/Pooling/Normalize, Trainer)
|
||||
# 5. bitsandbytes (Linear4bit, dequantize_4bit)
|
||||
#
|
||||
# Strategy: GitHub raw-fetch + symbol grep against every tracked
|
||||
# version (no pip install, CPU-only). When upstream renames a symbol
|
||||
# we depend on, the matching test fails BEFORE a user hits it. The
|
||||
# `main` branch entries give us a few-day lead on PyPI releases.
|
||||
#
|
||||
# Cross-references:
|
||||
# tests/vllm_compat/test_vllm_pinned_symbols.py (vLLM symbols)
|
||||
# tests/version_compat/test_trl_grpo_pinned_symbols.py
|
||||
# tests/version_compat/test_peft_pinned_symbols.py
|
||||
# tests/version_compat/test_sentence_transformers_pinned_symbols.py
|
||||
# tests/version_compat/test_bitsandbytes_pinned_symbols.py
|
||||
|
||||
name: Version Compat CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# Trigger on any unsloth source change, not just the three previously
|
||||
# named files. The symbol-existence tests verify that EVERY pinned
|
||||
# upstream reference in unsloth still resolves; a new
|
||||
# `from peft.foo import Bar` added in unsloth/kernels/whatever.py
|
||||
# is just as much a compat regression risk as one added in
|
||||
# unsloth/models/rl.py.
|
||||
paths:
|
||||
- 'unsloth/**'
|
||||
- 'tests/vllm_compat/**'
|
||||
- 'tests/version_compat/**'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/version-compat-ci.yml'
|
||||
schedule:
|
||||
# Daily 06:43 UTC. Catches upstream PyPI releases roughly within
|
||||
# 24 h. Off the :00 / :30 fleet-collision spots.
|
||||
- cron: '43 6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
vllm-pinned-symbols:
|
||||
name: vLLM pinned-symbol matrix (≥ 0.9.0 + main)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 12
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest only
|
||||
# The test fetches from raw.githubusercontent.com and greps
|
||||
# source. No pip install of vllm / torch / transformers is
|
||||
# needed — that's the whole point of this canary.
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'pytest>=8'
|
||||
- name: Run vllm-compat suite
|
||||
env:
|
||||
# Authenticated requests get a 5000-req/h quota on raw
|
||||
# fetches; unauthenticated is 60/h and trips on the matrix.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python -m pytest tests/vllm_compat/test_vllm_pinned_symbols.py -v --tb=short
|
||||
|
||||
trl-grpo-pinned-symbols:
|
||||
name: TRL / GRPO pinned-symbol matrix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest only
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'pytest>=8'
|
||||
- name: Run trl-compat suite
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# PYTHONPATH=. so `from tests.version_compat._fetch import …`
|
||||
# works without an editable install of unsloth itself.
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_trl_grpo_pinned_symbols.py \
|
||||
-v --tb=short
|
||||
|
||||
peft-pinned-symbols:
|
||||
name: PEFT pinned-symbol matrix (pyproject window + main)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest only
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'pytest>=8'
|
||||
- name: Run peft-compat suite
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_peft_pinned_symbols.py \
|
||||
tests/version_compat/test_unsloth_zoo_save_merged_pinned_symbols.py \
|
||||
-v --tb=short
|
||||
|
||||
st-pinned-symbols:
|
||||
name: sentence-transformers pinned-symbol matrix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest only
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'pytest>=8'
|
||||
- name: Run sentence-transformers compat suite
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_sentence_transformers_pinned_symbols.py \
|
||||
-v --tb=short
|
||||
|
||||
bitsandbytes-pinned-symbols:
|
||||
name: bitsandbytes pinned-symbol matrix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest only
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'pytest>=8'
|
||||
- name: Run bitsandbytes compat suite
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_bitsandbytes_pinned_symbols.py \
|
||||
-v --tb=short
|
||||
|
||||
transformers-pinned-symbols:
|
||||
name: transformers pinned-symbol matrix (4.57.6 + 5.x + main)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 12
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest only
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install 'pytest>=8'
|
||||
- name: Run transformers compat suite
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_transformers_pinned_symbols.py \
|
||||
-v --tb=short
|
||||
|
||||
# Optional second layer: actually `pip install` ONE representative
|
||||
# version of each package and verify unsloth + unsloth-zoo modules
|
||||
# import on it under the existing CUDA spoof. CPU-only, runs on
|
||||
# ubuntu-latest. Catches the small set of breakages that the static
|
||||
# symbol check misses (e.g. import-time side effects).
|
||||
zoo-imports-under-spoof:
|
||||
name: unsloth_zoo vllm/grpo/peft/st modules import under CUDA spoof
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: unsloth
|
||||
- name: Clone unsloth-zoo @ main
|
||||
run: |
|
||||
# github.com occasionally 500s on the git fetch; retry so a
|
||||
# single upstream blip does not fail CI.
|
||||
for attempt in 1 2 3; do
|
||||
rm -rf "$RUNNER_TEMP/unsloth-zoo"
|
||||
if git clone --depth=1 https://github.com/unslothai/unsloth-zoo \
|
||||
"$RUNNER_TEMP/unsloth-zoo"; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "::error::git clone unsloth-zoo failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
delay=$((5 * attempt))
|
||||
echo "::warning::clone failed (attempt $attempt/3), retrying in ${delay}s..."
|
||||
sleep "$delay"
|
||||
done
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install CPU torch + supported pkg pins
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# CPU torch (vllm/peft/st all depend on it).
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
|
||||
'torch>=2.4,<2.11' 'torchvision<0.26' 'torchcodec<0.10'
|
||||
# torchcodec is a hard requirement on transformers 5.x:
|
||||
# transformers/audio_utils.py:55 does
|
||||
# `importlib.metadata.version("torchcodec")` UNCONDITIONALLY,
|
||||
# which raises PackageNotFoundError on a CPU runner that
|
||||
# otherwise has no audio path -- and that error trickles up
|
||||
# through every `import unsloth_zoo.<module>` because
|
||||
# unsloth-zoo's vision_utils transitively pulls
|
||||
# transformers.processing_utils (-> audio_utils). The 0.10
|
||||
# cap mirrors the torch 2.10 / torchvision 0.26 ABI window
|
||||
# we already pin above.
|
||||
# Ladder of supported floor versions per pyproject.toml.
|
||||
pip install \
|
||||
'transformers>=4.56,<5.6' 'trl>=0.22,<0.26' \
|
||||
'peft>=0.18.0' 'sentence-transformers>=5.0' \
|
||||
'accelerate>=1.0' 'datasets>=3.4,<5' \
|
||||
'bitsandbytes>=0.45.5' \
|
||||
sentencepiece protobuf safetensors numpy 'pytest>=8' \
|
||||
'huggingface_hub>=0.34' tqdm packaging psutil triton Pillow
|
||||
# Editable-install both repos so the test imports the
|
||||
# checkouts (not whatever stale PyPI version pip resolved).
|
||||
pip install --no-deps -e "$RUNNER_TEMP/unsloth-zoo"
|
||||
pip install --no-deps -e ./unsloth
|
||||
- name: Run vllm_compat zoo-imports tests under spoof
|
||||
env:
|
||||
UNSLOTH_IS_PRESENT: '1'
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
|
||||
run: |
|
||||
cd unsloth
|
||||
# tests/vllm_compat/test_unsloth_zoo_imports.py: narrow vllm/grpo
|
||||
# import gates (5 tests).
|
||||
# tests/vllm_compat/test_extended_module_imports.py: full sweep
|
||||
# of unsloth_zoo + unsloth.models.* modules + RL dispatch
|
||||
# table population + FastModel API surface under spoof
|
||||
# (~30 tests). Catches transformers / peft / bnb symbol pin
|
||||
# drift at module-top BEFORE any runtime call.
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/vllm_compat/test_unsloth_zoo_imports.py \
|
||||
tests/vllm_compat/test_extended_module_imports.py \
|
||||
-v --tb=short
|
||||
|
||||
# Fake-CUDA GRPO/SFT/DPO patch run against REAL TRL (latest + main). Unlike
|
||||
# the static symbol/source greps above, this drives unsloth's actual
|
||||
# source-transform patchers (models/rl.py + rl_replacements.py) on a CPU-only
|
||||
# runner under the tests/conftest.py spoof harness -- no GPU, no training.
|
||||
# Catches structural TRL drift the greps miss (e.g. TRL 1.7.0's 2->3-tuple
|
||||
# per-token-logps return, restructured PEFT ref-adapter block) by asserting
|
||||
# the generated Unsloth trainer still satisfies the transform contracts.
|
||||
grpo-fake-run:
|
||||
name: GRPO fake-run (latest + main TRL, CPU spoof)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 18
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: unsloth
|
||||
- name: Clone unsloth-zoo @ main
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
rm -rf "$RUNNER_TEMP/unsloth-zoo"
|
||||
if git clone --depth=1 https://github.com/unslothai/unsloth-zoo \
|
||||
"$RUNNER_TEMP/unsloth-zoo"; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "::error::git clone unsloth-zoo failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
delay=$((5 * attempt))
|
||||
echo "::warning::clone failed (attempt $attempt/3), retrying in ${delay}s..."
|
||||
sleep "$delay"
|
||||
done
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install CPU torch + ecosystem + TRL latest
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple \
|
||||
'torch>=2.4,<2.11' 'torchvision<0.26' 'torchcodec<0.10'
|
||||
# Ecosystem floors unsloth needs; TRL itself is installed last so it
|
||||
# can pull the transformers/peft it requires.
|
||||
pip install \
|
||||
'transformers>=4.57' 'peft>=0.18.0' 'accelerate>=1.0' 'datasets>=3.4,<5' \
|
||||
'bitsandbytes>=0.45.5' sentencepiece protobuf safetensors numpy 'pytest>=8' \
|
||||
'huggingface_hub>=0.34' tqdm packaging psutil triton Pillow
|
||||
pip install --upgrade trl
|
||||
pip install --no-deps -e "$RUNNER_TEMP/unsloth-zoo"
|
||||
pip install --no-deps -e ./unsloth
|
||||
- name: Fake-run vs TRL latest
|
||||
env:
|
||||
UNSLOTH_IS_PRESENT: '1'
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
# Disable dynamo/inductor at the process level, before conftest.py's early
|
||||
# `import unsloth`, so the GRPO hot path never compiles on the GPU-less runner
|
||||
# (defense in depth; the CPU fake-train also flips this at runtime).
|
||||
TORCHDYNAMO_DISABLE: '1'
|
||||
TORCH_COMPILE_DISABLE: '1'
|
||||
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
|
||||
run: |
|
||||
cd unsloth
|
||||
python -c "import trl; print('Resolved TRL', trl.__version__)"
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_trl_grpo_fake_run.py \
|
||||
tests/version_compat/test_trl_fake_train_cpu.py \
|
||||
-v --tb=short
|
||||
# `main` is scheduled/dispatch-only so PR jobs stay fast and a bleeding-edge
|
||||
# TRL break does not red every PR. github.event_name is valid in a step if.
|
||||
- name: Fake-run vs TRL main (scheduled / dispatch only)
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
UNSLOTH_IS_PRESENT: '1'
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
TORCHDYNAMO_DISABLE: '1'
|
||||
TORCH_COMPILE_DISABLE: '1'
|
||||
PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python
|
||||
run: |
|
||||
pip install --upgrade "git+https://github.com/huggingface/trl"
|
||||
cd unsloth
|
||||
python -c "import trl; print('Resolved TRL', trl.__version__)"
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/version_compat/test_trl_grpo_fake_run.py \
|
||||
tests/version_compat/test_trl_fake_train_cpu.py \
|
||||
-v --tb=short
|
||||
|
||||
# Daily-only: same suites but with --strict on importable upstream
|
||||
# tags. Schedule-only so PR jobs stay fast; cron tolerates a flake.
|
||||
daily-fresh-fetch:
|
||||
name: daily fresh-fetch sweep (cron only)
|
||||
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
- name: Install pytest
|
||||
run: pip install 'pytest>=8'
|
||||
- name: Run all version-compat suites in one process (no cache)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PYTHONPATH=. python -m pytest \
|
||||
tests/vllm_compat/test_vllm_pinned_symbols.py \
|
||||
tests/version_compat/ \
|
||||
-v --tb=short
|
||||
@@ -0,0 +1,136 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Builds the PyPI wheel from the PR branch, then verifies the built wheel
|
||||
# actually contains what we expect to ship and does NOT contain the broken
|
||||
# Studio bundle that 2026.5.1 published. This is the single workflow that
|
||||
# would have blocked the 2026.5.1 release before twine upload.
|
||||
#
|
||||
# Verified locally end-to-end against this branch:
|
||||
# - python -m build produces unsloth-<version>-py3-none-any.whl in 13s
|
||||
# - wheel content sanity passes:
|
||||
# lockfile shipped, frontend dist shipped,
|
||||
# no node_modules in wheel, no bun.lock in wheel,
|
||||
# main bundle has unstable_Provider hits=1 (assistant-ui internals only).
|
||||
# - Studio backend imports cleanly from the installed wheel with the
|
||||
# lightweight dep set below.
|
||||
|
||||
name: Wheel CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- '.github/workflows/wheel-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
wheel:
|
||||
name: Wheel build + content sanity + import smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Lockfile supply-chain audit (pre-install scan)
|
||||
run: python3 scripts/lockfile_supply_chain_audit.py
|
||||
|
||||
- name: Build frontend
|
||||
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
|
||||
# required for `vite build`. The pre-install lockfile structural
|
||||
# audit (lockfile_supply_chain_audit.py) is the practical defence
|
||||
# against the npm postinstall-dropper class -- it fires BEFORE any
|
||||
# tarball runs, on the injection pattern itself rather than an
|
||||
# advisory-DB lookup.
|
||||
run: |
|
||||
cd studio/frontend
|
||||
npm ci --no-fund --no-audit
|
||||
npm run build
|
||||
|
||||
- name: Build wheel + sdist
|
||||
run: |
|
||||
python -m pip install --upgrade pip build
|
||||
rm -rf dist build ./*.egg-info
|
||||
python -m build
|
||||
|
||||
- name: Wheel content sanity
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import zipfile, glob, sys
|
||||
w = glob.glob("dist/unsloth-*.whl")
|
||||
if not w:
|
||||
print("FAIL: no wheel produced"); sys.exit(2)
|
||||
w = w[0]
|
||||
print(f"wheel: {w}")
|
||||
with zipfile.ZipFile(w) as z:
|
||||
n = z.namelist()
|
||||
checks = {
|
||||
"lockfile shipped": any(s.endswith("studio/frontend/package-lock.json") for s in n),
|
||||
"frontend dist shipped": any(s.endswith("studio/frontend/dist/index.html") for s in n),
|
||||
"no node_modules": not any("studio/frontend/node_modules/" in s for s in n),
|
||||
"no bun.lock": not any(s.endswith("studio/frontend/bun.lock") for s in n),
|
||||
}
|
||||
js = [s for s in n
|
||||
if "studio/frontend/dist/assets/" in s
|
||||
and s.endswith(".js")
|
||||
and "/index-" in s]
|
||||
if not js:
|
||||
print("FAIL: no main bundle index-*.js in wheel"); sys.exit(2)
|
||||
data = z.read(js[0]).decode("utf-8", "replace")
|
||||
hits = data.count("unstable_Provider:")
|
||||
print(f"main bundle: {js[0]}")
|
||||
print(f"unstable_Provider hits: {hits} (>=4 indicates 2026.5.1 regression)")
|
||||
checks["bundle has no Studio unstable_Provider call site"] = (hits < 4)
|
||||
|
||||
print()
|
||||
for k, v in checks.items():
|
||||
print(f" [{'PASS' if v else 'FAIL'}] {k}")
|
||||
sys.exit(0 if all(checks.values()) else 1)
|
||||
PY
|
||||
|
||||
- name: Studio backend import smoke
|
||||
# Imports `studio.backend.main:app` from the freshly-installed wheel in
|
||||
# a clean venv. This catches the class of bug that 2026.5.1 shipped with:
|
||||
# frontend dist missing, package-lock.json missing, or the wheel's Python
|
||||
# source tree broken in a way that surfaces only at app construction time.
|
||||
run: |
|
||||
python -m venv /tmp/v
|
||||
/tmp/v/bin/pip install --upgrade pip
|
||||
/tmp/v/bin/pip install -r studio/backend/requirements/studio.txt
|
||||
/tmp/v/bin/pip install \
|
||||
python-multipart aiofiles sqlalchemy cryptography \
|
||||
pyyaml jinja2 mammoth unpdf requests \
|
||||
'numpy<3'
|
||||
/tmp/v/bin/pip install --no-deps dist/unsloth-*.whl
|
||||
# Run from /tmp so Python imports the installed package, not the source tree.
|
||||
cd /tmp
|
||||
/tmp/v/bin/python -c "from studio.backend.main import app; print('Studio backend OK:', app.title)"
|
||||
|
||||
- name: Upload wheel on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: unsloth-wheel
|
||||
path: dist/
|
||||
retention-days: 7
|
||||
Reference in New Issue
Block a user