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
@@ -0,0 +1,13 @@
|
||||
# Normalize Python files to LF line endings
|
||||
*.py text eol=lf
|
||||
|
||||
# Always check out shell scripts with LF endings. Without this rule a Windows
|
||||
# clone (core.autocrlf=true) rewrites them to CRLF, and the trailing \r breaks
|
||||
# them when run in WSL/Linux (e.g. `set -e` -> "set: Illegal option -").
|
||||
*.sh text eol=lf
|
||||
|
||||
# Normalize Studio frontend sources to LF. Scoped to the frontend tree (rather
|
||||
# than repo-wide *.ts/*.tsx/... rules) so the policy can't force LF on files
|
||||
# elsewhere. text=auto lets Git detect and leave binary assets (logos, fonts)
|
||||
# untouched while text files (.ts/.tsx/.json/.html/.svg/...) are stored as LF.
|
||||
studio/frontend/** text=auto eol=lf
|
||||
@@ -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: ["*"]
|
||||
...
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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})"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,241 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.class
|
||||
unsloth_compiled_cache/
|
||||
# Notebook-validator runtime PyPI metadata cache (CI repopulates).
|
||||
scripts/data/pypi_cache/
|
||||
# ML artifacts (large files)
|
||||
feature/
|
||||
outputs/
|
||||
exports/
|
||||
/datasets/
|
||||
studio/backend/assets/datasets/
|
||||
# Generated async worker / reviewer transcripts (never part of the product).
|
||||
studio/backend/async_task_outputs/
|
||||
unsloth_training_checkpoints/
|
||||
*.gguf
|
||||
*.safetensors
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.venv_overlay/
|
||||
.venv_t5/
|
||||
environment.yaml
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
.pre-commit-cache/
|
||||
|
||||
# PyPI configuration file and IDE/Editors
|
||||
.pypirc
|
||||
.vscode
|
||||
.idea/
|
||||
.claude/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# oh-my-codex
|
||||
.omx/
|
||||
|
||||
# Firebase
|
||||
firebase-debug.log
|
||||
|
||||
# Other
|
||||
resources/
|
||||
tmp/
|
||||
**/node_modules/
|
||||
auth.db
|
||||
|
||||
# Tauri local build/generated output
|
||||
studio/src-tauri/target/
|
||||
studio/src-tauri/gen/
|
||||
studio/src-tauri/artifacts/
|
||||
studio/src-tauri/icons/android/
|
||||
studio/src-tauri/icons/ios/
|
||||
studio/src-tauri/icons/128x128@2x.png
|
||||
studio/src-tauri/icons/64x64.png
|
||||
studio/src-tauri/icons/Square*Logo.png
|
||||
studio/src-tauri/icons/StoreLogo.png
|
||||
studio/src-tauri/icons/squarehq.png
|
||||
|
||||
# Local working docs
|
||||
**/CLAUDE.md
|
||||
**/claude.md
|
||||
**/AGENT.md
|
||||
**/agent.md
|
||||
docs/canvas-lab-architecture.md
|
||||
log_rtx.txt
|
||||
log.txt
|
||||
setup_leo.sh
|
||||
server.pid
|
||||
*.log
|
||||
# Ignore stray lockfiles; real npm projects opt back in below (npm ci needs them).
|
||||
package-lock.json
|
||||
!studio/frontend/package-lock.json
|
||||
!studio/backend/core/data_recipe/oxc-validator/package-lock.json
|
||||
!studio/package-lock.json
|
||||
llama.cpp/
|
||||
# Stray "~" dir some tools create from a literal ~ TMPDIR; never part of the repo.
|
||||
/~/
|
||||
@@ -0,0 +1,6 @@
|
||||
ci:
|
||||
autofix_prs: true
|
||||
autofix_prs_limit: 5
|
||||
autoupdate_schedule: monthly
|
||||
autoupdate_commit_msg: "chore: pre-commit autoupdate"
|
||||
skip: []
|
||||
@@ -0,0 +1,33 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.18
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
- --fix
|
||||
- --exit-non-zero-on-fix
|
||||
exclude: '\.ipynb$'
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff-format-with-kwargs
|
||||
name: Ruff format with kwarg spacing
|
||||
entry: scripts/run_ruff_format.py
|
||||
language: python
|
||||
types: [python]
|
||||
# Mirror ruff's [tool.ruff] extend-exclude so this hook does not
|
||||
# half-process files ruff itself skips (which produced churn).
|
||||
exclude: '(chat_templates|ollama_template_mappers|_auto_install|mapper)\.py$'
|
||||
additional_dependencies:
|
||||
- ruff==0.6.9
|
||||
# Re-pins allowScripts entries after dependency bumps. pre-commit.ci
|
||||
# pushes the fix to PR branches, Dependabot's included, so stale pins
|
||||
# heal without a human in the loop.
|
||||
- id: sync-allow-scripts-pins
|
||||
name: Sync allowScripts pins with the frontend lockfile
|
||||
# `python <script>` not a direct exec: autofix commits can drop the
|
||||
# executable bit, which kills shebang-style entries.
|
||||
entry: python scripts/sync_allow_scripts_pins.py
|
||||
args: [--fix]
|
||||
language: python
|
||||
files: ^studio/frontend/(package\.json|package-lock\.json)$
|
||||
pass_filenames: false
|
||||
@@ -0,0 +1,132 @@
|
||||
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at support@unsloth.ai.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -0,0 +1,35 @@
|
||||
# 🦥 Contributing to Unsloth
|
||||
|
||||
Thank you for not only using Unsloth but also for being interested in helping out! We value all contributions, whether they come in the form of code, ideas, support for others or just by simply spreading the word of Unsloth! 💕
|
||||
|
||||
- **[Support the Community](https://github.com/unslothai/unsloth/issues)**: Answer questions, review pull requests, or assist others in discussions.
|
||||
- **Fix Bugs**: Identify and resolve issues with the existing codebase.
|
||||
- **Submit Ideas**: Request new features or share enhancements you'd like to see.
|
||||
- **Develop Features**: Implement new functionality or improve existing tools which can be done via PRs.
|
||||
- **[Improve Documentation](https://docs.unsloth.ai/)**: Help by creating guides, FAQs, or enhancing clarity.
|
||||
|
||||
One of the best ways to support us is by spreading the word about Unsloth! Share how it’s powering your amazing projects in blog posts or social media, and inspire others to explore its potential. Even a simple star on our repo goes a long way in showing your support and helping the community grow. 🌟
|
||||
|
||||
## Submitting Issues
|
||||
If you find a bug or have a feature idea, we’d love to hear from you! Here’s how to make your submission stand out:
|
||||
|
||||
### Reporting Bugs
|
||||
1. **Search First**: Check if the issue has already been reported using GitHub’s search bar under Issues.
|
||||
2. **Details Matter**: Is this on Google Colab, Kaggle, or on another platform service? Are you using Unsloth's official notebook? Include your OS, Python version, and other relevant details. For bugs, a concise code snippet that reproduces the issue is incredibly helpful.
|
||||
3. **Be Thorough**: Attach screenshots, traceback logs, or any additional information that might speed up resolution.
|
||||
|
||||
## Spread the Word
|
||||
Your support extends beyond code:
|
||||
- Spread the word by writing about Unsloth in blogs or social media.
|
||||
- Share how Unsloth powers your projects.
|
||||
- Star our repository to show your appreciation.
|
||||
|
||||
Finally, please be mindful of our [Code of Conduct](https://github.com/unslothai/unsloth/blob/main/CODE_OF_CONDUCT.md) to ensure a welcoming and inclusive environment for everyone.
|
||||
|
||||
Thank you so much for reading and we hope you have lots of fun using Unsloth! 🦥
|
||||
|
||||
|
||||
## Pull Request Guidelines
|
||||
- Keep PRs focused on a single change
|
||||
- Include a concise description and motivation
|
||||
- Link related issues when applicable
|
||||
@@ -0,0 +1,664 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
Files under unsloth/*, tests/*, scripts/* are Apache 2.0 licensed.
|
||||
Files under studio/*, unsloth_cli/* which is optional to install are AGPLv3 licensed.
|
||||
@@ -0,0 +1,203 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [2024-] [Unsloth AI. Inc team, Daniel Han-Chen & Michael Han-Chen]
|
||||
Files under unsloth/*, tests/*, scripts/* are Apache 2.0 licensed.
|
||||
Files under studio/*, unsloth_cli/* which is optional to install are AGPLv3 licensed.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,323 @@
|
||||
<h1 align="center" style="margin:0;">
|
||||
<a href="https://unsloth.ai/docs"><picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/unslothai/unsloth/main/images/unsloth%20logo%20white%20text.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/unslothai/unsloth/main/images/unsloth%20logo%20black%20text.png">
|
||||
<img alt="Unsloth logo" src="https://raw.githubusercontent.com/unslothai/unsloth/main/images/unsloth%20logo%20black%20text.png" height="80" style="max-width:100%;">
|
||||
</picture></a>
|
||||
</h1>
|
||||
<h3 align="center" style="margin: 0; margin-top: 0;">
|
||||
Unsloth Studio lets you run and train models locally.
|
||||
</h3>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-features">Features</a> •
|
||||
<a href="#-install">Quickstart</a> •
|
||||
<a href="#-free-notebooks">Notebooks</a> •
|
||||
<a href="https://unsloth.ai/docs">Documentation</a>
|
||||
</p>
|
||||
<br>
|
||||
<a href="https://unsloth.ai/docs/new/studio">
|
||||
<img alt="unsloth studio ui homepage" src="https://github.com/user-attachments/assets/53ae17a9-d975-44ef-9686-efb4ebd0454d" style="max-width: 100%; margin-bottom: 0;"></a>
|
||||
|
||||
## ⚡ Get started
|
||||
|
||||
#### macOS, Linux, WSL:
|
||||
```bash
|
||||
curl -fsSL https://unsloth.ai/install.sh | sh
|
||||
```
|
||||
#### Windows:
|
||||
```powershell
|
||||
irm https://unsloth.ai/install.ps1 | iex
|
||||
```
|
||||
#### Community:
|
||||
|
||||
- [Discord](https://discord.gg/unsloth)
|
||||
- [𝕏 (Twitter)](https://x.com/UnslothAI)
|
||||
- [Reddit](https://reddit.com/r/unsloth)
|
||||
|
||||
## ⭐ Features
|
||||
Unsloth Studio (Beta) lets you run and train text, [audio](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning), [embedding](https://unsloth.ai/docs/new/embedding-finetuning), [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) models on Windows, Linux and macOS.
|
||||
|
||||
### Inference
|
||||
* **Search + download + run models** including GGUF, LoRA adapters, safetensors
|
||||
* **Export models**: [Save or export](https://unsloth.ai/docs/new/studio/export) models to GGUF, 16-bit safetensors and other formats.
|
||||
* **Tool calling**: Support for [self-healing tool calling](https://unsloth.ai/docs/new/studio/chat#auto-healing-tool-calling) and web search
|
||||
* **[Code execution](https://unsloth.ai/docs/new/studio/chat#code-execution)**: lets LLMs test code in Claude artifacts and sandbox environments
|
||||
* **[API inference endpoint](https://unsloth.ai/docs/basics/api)**: Deploy and run local LLMs in Claude Code, Codex tools with Unsloth
|
||||
* [Auto set inference settings](https://unsloth.ai/docs/new/studio/chat#auto-parameter-tuning) and customize chat templates.
|
||||
* We work directly with teams behind [gpt-oss](https://docs.unsloth.ai/new/gpt-oss-how-to-run-and-fine-tune#unsloth-fixes-for-gpt-oss), [Qwen3](https://www.reddit.com/r/LocalLLaMA/comments/1kaodxu/qwen3_unsloth_dynamic_ggufs_128k_context_bug_fixes/), [Llama 4](https://github.com/ggml-org/llama.cpp/pull/12889), [Mistral](https://huggingface.co/mistralai/Mistral-Medium-3.5-128B/discussions/18), [Gemma 1-3](https://news.ycombinator.com/item?id=39671146), and [Phi-4](https://unsloth.ai/blog/phi4), where we’ve fixed bugs that improve model accuracy.
|
||||
* Chat with images, audio, PDFs, code, DOCX and more. [Connect API providers](https://unsloth.ai/docs/integrations/connections) (OpenAI, Anthropic) or servers (vLLM, Ollama).
|
||||
### Training
|
||||
* Train and RL **500+ models** up to **2x faster** with up to **70% less VRAM**, with no accuracy loss.
|
||||
* Custom Triton and mathematical **kernels**. See some collabs we did with [PyTorch](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) and [Hugging Face](https://unsloth.ai/docs/new/faster-moe).
|
||||
* **Data Recipes**: [Auto-create datasets](https://unsloth.ai/docs/new/studio/data-recipe) from **PDF, CSV, DOCX** etc. Edit data in a visual-node workflow.
|
||||
* **[Reinforcement Learning](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide)** (RL): The most efficient [RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide) library, using **80% less VRAM** for GRPO, [FP8](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) etc.
|
||||
* Supports full fine-tuning, RL, pretraining, 4-bit, 16-bit and, FP8 training.
|
||||
* **Observability**: Monitor training live, track loss and GPU usage and customize graphs.
|
||||
* [Multi-GPU](https://unsloth.ai/docs/basics/multi-gpu-training-with-unsloth) training is supported, with major improvements coming soon.
|
||||
|
||||
## 📥 Install
|
||||
Unsloth can be used in two ways: through **[Unsloth Studio](https://unsloth.ai/docs/new/studio/)**, the web UI, or through **Unsloth Core**, the code-based version. Each has different requirements.
|
||||
|
||||
### Unsloth Studio (web UI)
|
||||
Unsloth Studio (Beta) works on **Windows, Linux, WSL** and **macOS**.
|
||||
|
||||
* **CPU:** Supported for Chat and Data Recipes currently
|
||||
* **NVIDIA:** Training works on RTX 30/40/50, Blackwell, DGX Spark, Station and more
|
||||
* **macOS:** Training, MLX and GGUF inference are ALL supported.
|
||||
* **AMD:** Chat + Data works. Train with [Unsloth Core](#unsloth-core-code-based). Studio support is out soon.
|
||||
* **Multi-GPU:** Available now, with a major upgrade on the way
|
||||
|
||||
#### macOS, Linux, WSL:
|
||||
```bash
|
||||
curl -fsSL https://unsloth.ai/install.sh | sh
|
||||
```
|
||||
Use the same command to update.
|
||||
|
||||
#### Windows:
|
||||
```powershell
|
||||
irm https://unsloth.ai/install.ps1 | iex
|
||||
```
|
||||
Use the same command to update.
|
||||
|
||||
#### Launch
|
||||
```bash
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally.
|
||||
|
||||
To reach Studio over HTTPS, use `unsloth studio --secure`. Studio stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Studio reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below).
|
||||
|
||||
#### Docker
|
||||
Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run:
|
||||
```bash
|
||||
docker run -d -e JUPYTER_PASSWORD="mypassword" \
|
||||
-p 8888:8888 -p 8000:8000 -p 2222:22 \
|
||||
-v $(pwd)/work:/workspace/work \
|
||||
--gpus all \
|
||||
unsloth/unsloth
|
||||
```
|
||||
|
||||
#### Developer, Nightly, Uninstall
|
||||
To see developer, nightly and uninstallation etc. instructions, see [advanced installation](#-advanced-installation).
|
||||
|
||||
### Unsloth Core (code-based)
|
||||
#### Linux, WSL:
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
uv venv unsloth_env --python 3.13
|
||||
source unsloth_env/bin/activate
|
||||
uv pip install unsloth --torch-backend=auto
|
||||
```
|
||||
#### Windows:
|
||||
```powershell
|
||||
winget install -e --id Python.Python.3.13
|
||||
winget install --id=astral-sh.uv -e
|
||||
uv venv unsloth_env --python 3.13
|
||||
.\unsloth_env\Scripts\activate
|
||||
uv pip install unsloth --torch-backend=auto
|
||||
```
|
||||
For Windows, `pip install unsloth` works only if you have PyTorch installed. Read our [Windows Guide](https://unsloth.ai/docs/get-started/install/windows-installation).
|
||||
You can use the same Docker image as Unsloth Studio.
|
||||
|
||||
#### AMD, Intel:
|
||||
For RTX 50x, B200, 6000 GPUs: `uv pip install unsloth --torch-backend=auto`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth). <br>
|
||||
To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/get-started/install/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel).
|
||||
|
||||
## 📒 Free Notebooks
|
||||
|
||||
Train for free with our notebooks. You can use our new [free Unsloth Studio notebook](https://colab.research.google.com/github/unslothai/unsloth/blob/main/studio/Unsloth_Studio_Colab.ipynb) to run and train models for free in a web UI.
|
||||
Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Add dataset, run, then deploy your trained model.
|
||||
|
||||
| Model | Free Notebooks | Performance | Memory use |
|
||||
|-----------|---------|--------|----------|
|
||||
| **Gemma 4 (E2B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Gemma4_(E2B)-Vision.ipynb) | 1.5x faster | 50% less |
|
||||
| **Qwen3.5 (4B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_5_(4B)_Vision.ipynb) | 1.5x faster | 60% less |
|
||||
| **gpt-oss (20B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/gpt-oss-(20B)-Fine-tuning.ipynb) | 2x faster | 70% less |
|
||||
| **Qwen3.5 GSPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_5_(4B)_Vision_GRPO.ipynb) | 2x faster | 70% less |
|
||||
| **gpt-oss (20B): GRPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/gpt-oss-(20B)-GRPO.ipynb) | 2x faster | 80% less |
|
||||
| **Qwen3: Advanced GRPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_(4B)-GRPO.ipynb) | 2x faster | 70% less |
|
||||
| **embeddinggemma (300M)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/EmbeddingGemma_(300M).ipynb) | 2x faster | 20% less |
|
||||
| **Mistral Ministral 3 (3B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Ministral_3_VL_(3B)_Vision.ipynb) | 1.5x faster | 60% less |
|
||||
| **Llama 3.1 (8B) Alpaca** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Llama3.1_(8B)-Alpaca.ipynb) | 2x faster | 70% less |
|
||||
| **Llama 3.2 Conversational** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Llama3.2_(1B_and_3B)-Conversational.ipynb) | 2x faster | 70% less |
|
||||
| **Orpheus-TTS (3B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Orpheus_(3B)-TTS.ipynb) | 1.5x faster | 50% less |
|
||||
|
||||
- See all our notebooks for: [Kaggle](https://github.com/unslothai/notebooks?tab=readme-ov-file#-kaggle-notebooks), [GRPO](https://unsloth.ai/docs/get-started/unsloth-notebooks#grpo-reasoning-rl-notebooks), [TTS](https://unsloth.ai/docs/get-started/unsloth-notebooks#text-to-speech-tts-notebooks), [embedding](https://unsloth.ai/docs/new/embedding-finetuning) & [Vision](https://unsloth.ai/docs/get-started/unsloth-notebooks#vision-multimodal-notebooks)
|
||||
- See [all our models](https://unsloth.ai/docs/get-started/unsloth-model-catalog) and [all our notebooks](https://unsloth.ai/docs/get-started/unsloth-notebooks)
|
||||
- See detailed documentation for Unsloth [here](https://unsloth.ai/docs)
|
||||
|
||||
## 🦥 Unsloth News
|
||||
- **Connections**: Connect any API provider (OpenAI, Anthropic) or server (vLLM, Ollama). [Guide](https://unsloth.ai/docs/integrations/connections)
|
||||
- **MTP**: Run Qwen3.6 MTP in Unsloth. MTP settings are autoset specific to your hardware. [Guide](https://unsloth.ai/docs/models/qwen3.6#mtp-guide)
|
||||
- **API inference endpoint**: Deploy and run local LLMs in Claude Code, Codex tools. [Guide](https://unsloth.ai/docs/basics/api)
|
||||
- **Qwen3.6**: Qwen3.6-35B-A3B can now be trained and run in Unsloth Studio. [Blog](https://unsloth.ai/docs/models/qwen3.6)
|
||||
- **Gemma 4**: Run and train Google’s new models directly in Unsloth. [Blog](https://unsloth.ai/docs/models/gemma-4)
|
||||
- **Introducing Unsloth Studio**: our new web UI for running and training LLMs. [Blog](https://unsloth.ai/docs/new/studio)
|
||||
- **Qwen3.5** - 0.8B, 2B, 4B, 9B, 27B, 35-A3B, 112B-A10B are now supported. [Guide + notebooks](https://unsloth.ai/docs/models/qwen3.5/fine-tune)
|
||||
- Train **MoE LLMs 12x faster** with 35% less VRAM - DeepSeek, GLM, Qwen and gpt-oss. [Blog](https://unsloth.ai/docs/new/faster-moe)
|
||||
- **Embedding models**: Unsloth now supports ~1.8-3.3x faster embedding fine-tuning. [Blog](https://unsloth.ai/docs/new/embedding-finetuning) • [Notebooks](https://unsloth.ai/docs/get-started/unsloth-notebooks#embedding-models)
|
||||
- New **7x longer context RL** vs. all other setups, via our new batching algorithms. [Blog](https://unsloth.ai/docs/new/grpo-long-context)
|
||||
- New RoPE & MLP **Triton Kernels** & **Padding Free + Packing**: 3x faster training & 30% less VRAM. [Blog](https://unsloth.ai/docs/new/3x-faster-training-packing)
|
||||
- **500K Context**: Training a 20B model with >500K context is now possible on an 80GB GPU. [Blog](https://unsloth.ai/docs/blog/500k-context-length-fine-tuning)
|
||||
- **FP8 & Vision RL**: You can now do FP8 & VLM GRPO on consumer GPUs. [FP8 Blog](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) • [Vision RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/vision-reinforcement-learning-vlm-rl)
|
||||
|
||||
## 📥 Advanced Installation
|
||||
The below advanced instructions are for Unsloth Studio. For Unsloth Core advanced installation, [view our docs](https://unsloth.ai/docs/get-started/install/pip-install#advanced-pip-installation).
|
||||
#### Developer / Nightly / Experimental installs: macOS, Linux, WSL:
|
||||
The developer install builds from the `main` branch, which is the latest (nightly) source.
|
||||
```bash
|
||||
git clone https://github.com/unslothai/unsloth
|
||||
cd unsloth
|
||||
./install.sh --local
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
To install into an isolated location (its own virtual env, `auth/`, `studio.db`, cache and llama.cpp build), set `UNSLOTH_STUDIO_HOME` and pass it again at launch:
|
||||
```bash
|
||||
UNSLOTH_STUDIO_HOME="$PWD/.studio" ./install.sh --local
|
||||
UNSLOTH_STUDIO_HOME="$PWD/.studio" unsloth studio -p 8888
|
||||
```
|
||||
Then to update :
|
||||
```bash
|
||||
cd unsloth && git pull
|
||||
./install.sh --local
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
|
||||
#### Developer / Nightly / Experimental installs: Windows PowerShell:
|
||||
The developer install builds from the `main` branch, which is the latest (nightly) source.
|
||||
```powershell
|
||||
git clone https://github.com/unslothai/unsloth.git
|
||||
cd unsloth
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
.\install.ps1 --local
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
To install into an isolated location (its own virtual env, `auth/`, `studio.db`, cache and llama.cpp build), set `UNSLOTH_STUDIO_HOME` and pass it again at launch:
|
||||
```powershell
|
||||
$env:UNSLOTH_STUDIO_HOME="$PWD\.studio"; .\install.ps1 --local
|
||||
$env:UNSLOTH_STUDIO_HOME="$PWD\.studio"; unsloth studio -p 8888
|
||||
```
|
||||
Then to update :
|
||||
```powershell
|
||||
cd unsloth; git pull
|
||||
.\install.ps1 --local
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
|
||||
#### Remote access: `--secure` (HTTPS tunnel) vs raw port
|
||||
By default `unsloth studio` binds to `127.0.0.1` (this machine only). To reach it from another device, pick one of:
|
||||
|
||||
- `--secure` (recommended): serve **only** through a free Cloudflare HTTPS link. Studio stays bound to localhost and the tunnel provides the public URL; it fails closed (does not start) if the tunnel can't come up, so the raw port is never exposed.
|
||||
```bash
|
||||
unsloth studio --secure -p 8888
|
||||
```
|
||||
- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network. This also starts a public Cloudflare quick tunnel by default, which publishes an internet-reachable `https://*.trycloudflare.com` URL even behind a firewall. Both the raw port and the tunnel expose Studio beyond this machine, so only use this on a network you trust; pass `--no-cloudflare` to drop the public link while keeping the network bind.
|
||||
```bash
|
||||
unsloth studio -H 0.0.0.0 -p 8888
|
||||
```
|
||||
|
||||
Server-side tools (web search, Python and terminal code execution) run as your user and are on by default. Anyone who can reach the server with the API key can run code on this machine, so keep your API key private and pass `--disable-tools` when exposing Studio.
|
||||
|
||||
#### Advanced launch options
|
||||
Installer options can be passed as environment variables. On macOS, Linux and WSL place the variable after the pipe so the shell passes it to `sh`; on Windows set it with `$env:` before piping to `iex`.
|
||||
|
||||
Skip PyTorch (GGUF-only mode):
|
||||
```bash
|
||||
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh
|
||||
```
|
||||
```powershell
|
||||
$env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex
|
||||
```
|
||||
|
||||
Skip the post-install prompt that starts Studio (useful for automated installs):
|
||||
```bash
|
||||
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh
|
||||
```
|
||||
```powershell
|
||||
$env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex
|
||||
```
|
||||
|
||||
Pin the Python version:
|
||||
```bash
|
||||
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh
|
||||
```
|
||||
```powershell
|
||||
$env:UNSLOTH_PYTHON='3.12'; irm https://unsloth.ai/install.ps1 | iex
|
||||
```
|
||||
|
||||
Install to a custom location with `UNSLOTH_STUDIO_HOME`:
|
||||
```bash
|
||||
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh
|
||||
```
|
||||
```powershell
|
||||
$env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex
|
||||
```
|
||||
|
||||
On macOS, the installer defaults to the system certificate store (`UV_SYSTEM_CERTS=1`) so uv trusts the CAs in your Keychain, needed behind TLS-inspecting proxies (Cisco Umbrella, Zscaler, etc.). Opt out with:
|
||||
```bash
|
||||
curl -fsSL https://unsloth.ai/install.sh | UV_SYSTEM_CERTS=0 sh
|
||||
```
|
||||
|
||||
Point the frontend build at a corporate npm mirror/proxy with `UNSLOTH_NPM_REGISTRY` (for the developer install behind a firewall that blocks `registry.npmjs.org`):
|
||||
```bash
|
||||
UNSLOTH_NPM_REGISTRY=https://artifactory.example.com/api/npm/npm/ ./install.sh --local
|
||||
```
|
||||
```powershell
|
||||
$env:UNSLOTH_NPM_REGISTRY='https://artifactory.example.com/api/npm/npm/'; .\install.ps1 --local
|
||||
```
|
||||
It is threaded as `--registry` into the Studio frontend `npm`/`bun` installs; the supply-chain locks (7-day `min-release-age`, exact version pins) stay in force.
|
||||
|
||||
Cap Studio's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`.
|
||||
|
||||
#### Uninstall
|
||||
The recommended way to fully remove Unsloth Studio is the matching uninstall script for your OS. It stops any running servers, removes the install dir, the launcher data dir, the desktop shortcut, and any platform-specific entries (macOS `.app` bundle + Launch Services on Mac; Start Menu, `HKCU\Software\Unsloth` registry key and user `PATH` entries on Windows):
|
||||
|
||||
* **MacOS, WSL, Linux:** `curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.sh | sh`
|
||||
* **Windows (PowerShell):** `irm https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.ps1 | iex`
|
||||
|
||||
If you only want to drop the install dir and keep the launcher/shortcut for a later reinstall, you can instead run `rm -rf ~/.unsloth/studio` (Mac/Linux/WSL) or `Remove-Item -Recurse -Force "$HOME\.unsloth\studio"` (Windows). The model cache at `~/.cache/huggingface` is not touched by any of these.
|
||||
|
||||
For more info, [see our docs](https://unsloth.ai/docs/new/studio/install#uninstall).
|
||||
|
||||
#### Deleting model files
|
||||
|
||||
You can delete old model files either from the bin icon in model search or by removing the relevant cached model folder from the default Hugging Face cache directory. By default, HF uses:
|
||||
|
||||
* **MacOS, Linux, WSL:** `~/.cache/huggingface/hub/`
|
||||
* **Windows:** `%USERPROFILE%\.cache\huggingface\hub\`
|
||||
|
||||
## 💚 Community and Links
|
||||
| Type | Links |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| <img width="16" src="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/66e3d80db9971f10a9757c99_Symbol.svg" /> **Discord** | [Join Discord server](https://discord.com/invite/unsloth) |
|
||||
| <img width="15" src="https://redditinc.com/hs-fs/hubfs/Reddit%20Inc/Brand/Reddit_Logo.png" /> **r/unsloth Reddit** | [Join Reddit community](https://reddit.com/r/unsloth) |
|
||||
| 📚 **Documentation & Wiki** | [Read Our Docs](https://unsloth.ai/docs) |
|
||||
| <img width="13" src="https://upload.wikimedia.org/wikipedia/commons/0/09/X_(formerly_Twitter)_logo_late_2025.svg" /> **Twitter (aka X)** | [Follow us on X](https://twitter.com/unslothai) |
|
||||
| 🔮 **Our Models** | [Unsloth Catalog](https://unsloth.ai/docs/get-started/unsloth-model-catalog) |
|
||||
| ✍️ **Blog** | [Read our Blogs](https://unsloth.ai/blog) |
|
||||
|
||||
### Citation
|
||||
|
||||
You can cite the Unsloth repo as follows:
|
||||
```bibtex
|
||||
@software{unsloth,
|
||||
author = {Daniel Han, Michael Han and Unsloth team},
|
||||
title = {Unsloth},
|
||||
url = {https://github.com/unslothai/unsloth},
|
||||
year = {2023}
|
||||
}
|
||||
```
|
||||
If you trained a model with 🦥Unsloth, you can use this cool sticker! <img src="https://raw.githubusercontent.com/unslothai/unsloth/main/images/made with unsloth.png" width="200" align="center" />
|
||||
|
||||
### License
|
||||
Unsloth uses a dual-licensing model of Apache 2.0 and AGPL-3.0. The core Unsloth package remains licensed under **[Apache 2.0](https://github.com/unslothai/unsloth?tab=Apache-2.0-1-ov-file)**, while certain optional components, such as the Unsloth Studio UI are licensed under the open-source license **[AGPL-3.0](https://github.com/unslothai/unsloth?tab=AGPL-3.0-2-ov-file)**.
|
||||
|
||||
This structure helps support ongoing Unsloth development while keeping the project open source and enabling the broader ecosystem to continue growing.
|
||||
|
||||
### Thank You to
|
||||
- The [llama.cpp library](https://github.com/ggml-org/llama.cpp) that lets users run and save models with Unsloth
|
||||
- The Hugging Face team and their libraries: [transformers](https://github.com/huggingface/transformers) and [TRL](https://github.com/huggingface/trl)
|
||||
- The Pytorch and [Torch AO](https://github.com/unslothai/unsloth/pull/3391) team for their contributions
|
||||
- NVIDIA for their [NeMo DataDesigner](https://github.com/NVIDIA-NeMo/DataDesigner) library and their contributions
|
||||
- And of course for every single person who has contributed or has used Unsloth!
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`unslothai/unsloth`
|
||||
- 原始仓库:https://github.com/unslothai/unsloth
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/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
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# PyPI/Studio release publishing must use `./build.sh publish` (or an
|
||||
# equivalent stamp -> build -> verify-dist -> upload flow) so packaged Studio
|
||||
# artifacts include the display-only Studio release version.
|
||||
|
||||
# 1. Build frontend (Vite outputs to dist/)
|
||||
cd studio/frontend
|
||||
|
||||
# Clean stale dist to force a full rebuild
|
||||
rm -rf dist
|
||||
|
||||
# Tailwind v4's oxide scanner respects .gitignore in parent directories.
|
||||
# Python venvs create a .gitignore with "*" (ignore everything), which
|
||||
# prevents Tailwind from scanning .tsx source files for class names.
|
||||
# Temporarily hide any such .gitignore during the build, then restore it.
|
||||
_HIDDEN_GITIGNORES=()
|
||||
_dir="$(pwd)"
|
||||
while [ "$_dir" != "/" ]; do
|
||||
_dir="$(dirname "$_dir")"
|
||||
if [ -f "$_dir/.gitignore" ] && grep -qx '\*' "$_dir/.gitignore" 2>/dev/null; then
|
||||
mv "$_dir/.gitignore" "$_dir/.gitignore._twbuild"
|
||||
_HIDDEN_GITIGNORES+=("$_dir/.gitignore")
|
||||
fi
|
||||
done
|
||||
|
||||
_restore_gitignores() {
|
||||
for _gi in "${_HIDDEN_GITIGNORES[@]+"${_HIDDEN_GITIGNORES[@]}"}"; do
|
||||
mv "${_gi}._twbuild" "$_gi" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
trap _restore_gitignores EXIT
|
||||
|
||||
# Corporate-mirror / proxy escape hatch (#6491). When UNSLOTH_NPM_REGISTRY is set we
|
||||
# thread it as `--registry <url>` into the installs (overrides frontend/.npmrc's pinned
|
||||
# registry for both bun and npm; min-release-age / save-exact stay in force). Empty
|
||||
# array (the default) expands to nothing under `set -u`.
|
||||
_NPM_REGISTRY_ARGS=()
|
||||
if [ -n "${UNSLOTH_NPM_REGISTRY:-}" ]; then
|
||||
_NPM_REGISTRY_ARGS=(--registry "$UNSLOTH_NPM_REGISTRY")
|
||||
fi
|
||||
|
||||
# Use bun for install if available (faster), fall back to npm.
|
||||
_install_ok=false
|
||||
if command -v bun &>/dev/null; then
|
||||
if bun install "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}"; then
|
||||
_install_ok=true
|
||||
else
|
||||
echo "⚠ bun install failed, falling back to npm"
|
||||
rm -rf node_modules
|
||||
fi
|
||||
fi
|
||||
if [ "$_install_ok" != "true" ]; then
|
||||
if ! npm install "${_NPM_REGISTRY_ARGS[@]+"${_NPM_REGISTRY_ARGS[@]}"}"; then
|
||||
echo "❌ ERROR: package install failed" >&2
|
||||
echo " If you are behind a corporate firewall/proxy, set UNSLOTH_NPM_REGISTRY to your mirror and retry, e.g.:" >&2
|
||||
echo " UNSLOTH_NPM_REGISTRY=https://your-mirror.example/api/npm/ ./build.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
npm run build # outputs to studio/frontend/dist/
|
||||
|
||||
_restore_gitignores
|
||||
trap - EXIT
|
||||
|
||||
# Validate CSS output -- catch truncated Tailwind builds before packaging
|
||||
MAX_CSS_SIZE=$(find dist/assets -name '*.css' -exec wc -c {} + 2>/dev/null | sort -n | tail -1 | awk '{print $1}')
|
||||
if [ -z "$MAX_CSS_SIZE" ]; then
|
||||
echo "❌ ERROR: No CSS files were emitted into dist/assets."
|
||||
echo " The frontend build may have failed silently."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$MAX_CSS_SIZE" -lt 100000 ]; then
|
||||
echo "❌ ERROR: Largest CSS file is only $((MAX_CSS_SIZE / 1024))KB (expected >100KB)."
|
||||
echo " Tailwind may not have scanned all source files."
|
||||
echo " Check for .gitignore files blocking the Tailwind oxide scanner."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Frontend CSS validated (${MAX_CSS_SIZE} bytes)"
|
||||
|
||||
cd ../..
|
||||
|
||||
# 2. Clean old artifacts
|
||||
rm -rf build dist *.egg-info
|
||||
|
||||
# 3. Stamp display-only Studio release metadata for packaged builds.
|
||||
_STUDIO_BUILD_INFO="studio/backend/utils/_studio_release_build.py"
|
||||
_STUDIO_BUILD_INFO_BACKUP="$(mktemp)"
|
||||
cp "$_STUDIO_BUILD_INFO" "$_STUDIO_BUILD_INFO_BACKUP"
|
||||
_restore_studio_build_info() {
|
||||
cp "$_STUDIO_BUILD_INFO_BACKUP" "$_STUDIO_BUILD_INFO" 2>/dev/null || true
|
||||
rm -f "$_STUDIO_BUILD_INFO_BACKUP"
|
||||
}
|
||||
trap _restore_studio_build_info EXIT
|
||||
|
||||
if [ "${1:-}" = "publish" ]; then
|
||||
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py --require-release)"
|
||||
else
|
||||
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)"
|
||||
fi
|
||||
|
||||
# 4. Build wheel/sdist
|
||||
python -m build
|
||||
|
||||
if [ "${1:-}" = "publish" ]; then
|
||||
python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION"
|
||||
fi
|
||||
|
||||
_restore_studio_build_info
|
||||
trap - EXIT
|
||||
|
||||
# 5. Optionally publish
|
||||
if [ "${1:-}" = "publish" ]; then
|
||||
python -m twine upload dist/*
|
||||
fi
|
||||
@@ -0,0 +1,7 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from unsloth_cli import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 871 KiB |
|
After Width: | Height: | Size: 771 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""Diff two `package-lock.json` files and flag NEW install-script deps.
|
||||
|
||||
A `"hasInstallScript": true` package runs preinstall/install/postinstall
|
||||
hooks on every `npm ci` -- the lever behind recent npm supply-chain
|
||||
compromises (attacker publishes a malicious version of a trusted dep).
|
||||
This refuses to land a newly-introduced install-script dep without a
|
||||
maintainer eyeball; pre-existing ones are not re-flagged.
|
||||
|
||||
Supports lockfileVersion 1 (recursive `dependencies`) and 2/3 (flat
|
||||
`packages` with `node_modules/.../node_modules/...` nesting). For each
|
||||
new entry we best-effort fetch the registry metadata to recover the
|
||||
postinstall command body; the finding is still emitted if unreachable.
|
||||
|
||||
Exit codes: 0 = none; 1 = one or more (on stderr); 2 = internal error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REGISTRY_BASE = "https://registry.npmjs.org/"
|
||||
REGISTRY_TIMEOUT_SECS = 5
|
||||
|
||||
CRITICAL = "CRITICAL"
|
||||
HIGH = "HIGH"
|
||||
|
||||
|
||||
class Finding:
|
||||
__slots__ = ("severity", "name", "version", "kind", "detail")
|
||||
|
||||
def __init__(self, severity: str, name: str, version: str, kind: str, detail: str) -> None:
|
||||
self.severity = severity
|
||||
self.name = name
|
||||
self.version = version
|
||||
self.kind = kind
|
||||
self.detail = detail
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f" [{self.severity}] {self.name}@{self.version}\n"
|
||||
f" kind: {self.kind}\n"
|
||||
f" detail: {self.detail}"
|
||||
)
|
||||
|
||||
|
||||
# Lockfile parsing.
|
||||
|
||||
|
||||
def _strip_nm_prefix(key: str) -> str:
|
||||
"""Convert a v2/v3 `packages` key into a bare package name (leaf after last `node_modules/`)."""
|
||||
if not key:
|
||||
return ""
|
||||
# LAST node_modules/ segment so transitives map to their leaf name.
|
||||
marker = "node_modules/"
|
||||
idx = key.rfind(marker)
|
||||
if idx == -1:
|
||||
return key
|
||||
return key[idx + len(marker) :]
|
||||
|
||||
|
||||
def _collect_install_script_entries(lock: dict) -> dict[str, str]:
|
||||
"""Return {name@version: name} for entries with hasInstallScript (v2/v3) or a lifecycle script (v1).
|
||||
|
||||
Keyed by name@version so dup copies at different versions aren't lost.
|
||||
"""
|
||||
seen: dict[str, str] = {}
|
||||
version = lock.get("lockfileVersion")
|
||||
|
||||
# v2 / v3: flat `packages` map.
|
||||
packages = lock.get("packages") or {}
|
||||
for key, entry in packages.items():
|
||||
if key == "" or not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("link"):
|
||||
continue
|
||||
if not entry.get("hasInstallScript"):
|
||||
continue
|
||||
name = _strip_nm_prefix(key)
|
||||
if not name:
|
||||
continue
|
||||
ver = entry.get("version") or "<unversioned>"
|
||||
seen[f"{name}@{ver}"] = name
|
||||
|
||||
# v1 has no hasInstallScript flag; detect lifecycle scripts directly.
|
||||
def _walk_v1(deps: dict, depth: int = 0) -> None:
|
||||
if depth > 64 or not isinstance(deps, dict):
|
||||
return
|
||||
for name, entry in deps.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
scripts = entry.get("scripts") or {}
|
||||
lifecycle = any(
|
||||
isinstance(scripts, dict) and scripts.get(hook)
|
||||
for hook in ("preinstall", "install", "postinstall")
|
||||
)
|
||||
if lifecycle:
|
||||
ver = entry.get("version") or "<unversioned>"
|
||||
seen[f"{name}@{ver}"] = name
|
||||
_walk_v1(entry.get("dependencies"), depth = depth + 1)
|
||||
|
||||
if version == 1 or "dependencies" in lock:
|
||||
_walk_v1(lock.get("dependencies") or {})
|
||||
|
||||
return seen
|
||||
|
||||
|
||||
def _load_lockfile(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"lockfile not found: {path}")
|
||||
try:
|
||||
return json.loads(path.read_text(encoding = "utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"{path}: not valid JSON: {exc}") from exc
|
||||
|
||||
|
||||
# Registry lookup for the postinstall command body (best-effort).
|
||||
|
||||
|
||||
def _fetch_registry_scripts(name: str, version: str) -> dict[str, str] | None:
|
||||
"""Return {hook: command} for lifecycle hooks in registry metadata; None on any error (never raises)."""
|
||||
safe_name = urllib.parse.quote(name, safe = "@/")
|
||||
url = f"{REGISTRY_BASE}{safe_name}/{urllib.parse.quote(version)}"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout = REGISTRY_TIMEOUT_SECS) as resp:
|
||||
body = resp.read()
|
||||
except (urllib.error.URLError, OSError, ValueError, TimeoutError):
|
||||
return None
|
||||
try:
|
||||
meta = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
scripts = meta.get("scripts") or {}
|
||||
if not isinstance(scripts, dict):
|
||||
return None
|
||||
keep = {}
|
||||
for hook in ("preinstall", "install", "postinstall"):
|
||||
cmd = scripts.get(hook)
|
||||
if isinstance(cmd, str) and cmd.strip():
|
||||
keep[hook] = cmd
|
||||
return keep or None
|
||||
|
||||
|
||||
# Diff.
|
||||
|
||||
|
||||
def diff_new_install_scripts(base_lock: dict, head_lock: dict) -> list[Finding]:
|
||||
base = _collect_install_script_entries(base_lock)
|
||||
head = _collect_install_script_entries(head_lock)
|
||||
findings: list[Finding] = []
|
||||
for key in sorted(head):
|
||||
if key in base:
|
||||
continue # pre-existing install-script dep; not in scope
|
||||
name = head[key]
|
||||
version = key[len(name) + 1 :] if key.startswith(name + "@") else "<unversioned>"
|
||||
scripts = _fetch_registry_scripts(name, version)
|
||||
if scripts:
|
||||
detail = "; ".join(f"{h}={cmd!r}" for h, cmd in scripts.items())
|
||||
else:
|
||||
detail = (
|
||||
"newly added with hasInstallScript=true; registry "
|
||||
"metadata unreachable -- inspect the package's "
|
||||
"scripts.{preinstall,install,postinstall} manually"
|
||||
)
|
||||
findings.append(
|
||||
Finding(
|
||||
severity = CRITICAL,
|
||||
name = name,
|
||||
version = version,
|
||||
kind = "new-install-script",
|
||||
detail = detail,
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
# CLI.
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description = (
|
||||
"Diff two package-lock.json files and refuse any newly-added install-script dep."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base",
|
||||
required = True,
|
||||
help = "Path to the BASE package-lock.json (e.g. main branch).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--head",
|
||||
required = True,
|
||||
help = "Path to the HEAD package-lock.json (this PR).",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
base_lock = _load_lockfile(Path(args.base))
|
||||
head_lock = _load_lockfile(Path(args.head))
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
print(f"[install-script-diff] ERROR: {exc}", file = sys.stderr)
|
||||
return 2
|
||||
|
||||
findings = diff_new_install_scripts(base_lock, head_lock)
|
||||
if not findings:
|
||||
print(
|
||||
"[install-script-diff] OK: no newly-added install-script "
|
||||
"dependencies between base and head",
|
||||
flush = True,
|
||||
)
|
||||
return 0
|
||||
|
||||
print(
|
||||
f"\n[install-script-diff] FAIL: {len(findings)} newly-added "
|
||||
f"install-script dependency(ies):\n",
|
||||
file = sys.stderr,
|
||||
)
|
||||
for f in findings:
|
||||
print(str(f), file = sys.stderr)
|
||||
print(file = sys.stderr)
|
||||
print(
|
||||
"[install-script-diff] Refusing to proceed. Every new "
|
||||
"install-script dep is a postinstall lifecycle hook that "
|
||||
"would run on the next `npm ci`. Review each finding above, "
|
||||
"confirm the maintainer + version, and re-run.",
|
||||
file = sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,9 @@
|
||||
# Do not modify this file directly; it is generated by extract_colabx_testing_tarballs.sh via
|
||||
# $ (lsb_release -ds;python --version;) > os-info-gpu.txt
|
||||
# Be aware that this list does not necessarily reflect the current state of the
|
||||
# staging or production container, but rather the state as of the most recent
|
||||
# submitted CL where extract_colabx_testing_tarballs.sh was run.
|
||||
Ubuntu 22.04.5 LTS
|
||||
Python 3.12.13
|
||||
R version 4.5.3 (2026-03-11) -- "Reassured Reassurer"
|
||||
julia version 1.12.6
|
||||
@@ -0,0 +1,731 @@
|
||||
# Do not modify this file directly; it is generated by extract_colabx_testing_tarballs.sh via
|
||||
# $ python3 -m pip freeze
|
||||
# Be aware that this list does not necessarily reflect the current state of the
|
||||
# staging or production container, but rather the state as of the most recent
|
||||
# submitted CL where extract_colabx_testing_tarballs.sh was run.
|
||||
absl-py==1.4.0
|
||||
accelerate==1.13.0
|
||||
access==1.1.10.post3
|
||||
affine==2.4.0
|
||||
aiofiles==24.1.0
|
||||
aiohappyeyeballs==2.6.1
|
||||
aiohttp==3.13.5
|
||||
aiosignal==1.4.0
|
||||
aiosqlite==0.22.1
|
||||
alabaster==1.0.0
|
||||
albucore==0.0.24
|
||||
albumentations==2.0.8
|
||||
ale-py==0.11.2
|
||||
alembic==1.18.4
|
||||
altair==5.5.0
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
antlr4-python3-runtime==4.9.3
|
||||
anyio==4.13.0
|
||||
anywidget==0.9.21
|
||||
apsw==3.53.0.0
|
||||
apswutils==0.1.2
|
||||
argon2-cffi==25.1.0
|
||||
argon2-cffi-bindings==25.1.0
|
||||
array_record==0.8.3
|
||||
arrow==1.4.0
|
||||
arviz==0.22.0
|
||||
astropy==7.2.0
|
||||
astropy-iers-data==0.2026.4.20.0.58.15
|
||||
astunparse==1.6.3
|
||||
atpublic==5.1
|
||||
attrs==26.1.0
|
||||
audioread==3.1.0
|
||||
Authlib==1.6.11
|
||||
autograd==1.8.0
|
||||
babel==2.18.0
|
||||
backcall==0.2.0
|
||||
beartype==0.22.9
|
||||
beautifulsoup4==4.13.5
|
||||
betterproto==2.0.0b6
|
||||
bigframes==2.39.0
|
||||
bigquery-magics==0.14.0
|
||||
bleach==6.3.0
|
||||
blinker==1.9.0
|
||||
blis==1.3.3
|
||||
blobfile==3.2.0
|
||||
blosc2==4.1.2
|
||||
bokeh==3.8.2
|
||||
Bottleneck==1.4.2
|
||||
bqplot==0.12.45
|
||||
branca==0.8.2
|
||||
brotli==1.2.0
|
||||
CacheControl==0.14.4
|
||||
cachetools==6.2.6
|
||||
catalogue==2.0.10
|
||||
certifi==2026.4.22
|
||||
cffi==2.0.0
|
||||
chardet==5.2.0
|
||||
charset-normalizer==3.4.7
|
||||
clarabel==0.11.1
|
||||
click==8.3.3
|
||||
click-plugins==1.1.1.2
|
||||
cligj==0.7.2
|
||||
cloudpathlib==0.23.0
|
||||
cloudpickle==3.1.2
|
||||
cmake==3.31.10
|
||||
cmdstanpy==1.3.0
|
||||
colorcet==3.1.0
|
||||
colorlover==0.3.0
|
||||
community==1.0.0b1
|
||||
confection==1.3.3
|
||||
cons==0.4.7
|
||||
contourpy==1.3.3
|
||||
cramjam==2.11.0
|
||||
cryptography==43.0.3
|
||||
cucim-cu12 @ https://pypi.nvidia.com/cucim-cu12/cucim_cu12-26.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
|
||||
cuda-bindings==12.9.4
|
||||
cuda-core==0.3.2
|
||||
cuda-pathfinder==1.5.3
|
||||
cuda-python==12.9.4
|
||||
cuda-toolkit==12.8.1
|
||||
cudf-cu12==26.2.1
|
||||
cudf-polars-cu12==26.2.1
|
||||
cufflinks==0.17.3
|
||||
cuml-cu12==26.2.0
|
||||
cupy-cuda12x==14.0.1
|
||||
curl_cffi==0.15.0
|
||||
cuvs-cu12 @ https://pypi.nvidia.com/cuvs-cu12/cuvs_cu12-26.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
|
||||
cvxopt==1.3.2
|
||||
cvxpy==1.6.7
|
||||
cycler==0.12.1
|
||||
cyipopt==1.5.0
|
||||
cymem==2.0.13
|
||||
Cython==3.0.12
|
||||
dask==2026.1.1
|
||||
dask-cuda==26.2.0
|
||||
dask-cudf-cu12==26.2.1
|
||||
dataproc-spark-connect==1.1.0
|
||||
datasets==4.0.0
|
||||
db-dtypes==1.5.1
|
||||
dbus-python==1.2.18
|
||||
debugpy==1.8.15
|
||||
decorator==4.4.2
|
||||
defusedxml==0.7.1
|
||||
deprecation==2.1.0
|
||||
diffusers==0.37.1
|
||||
dill==0.3.8
|
||||
distributed==2026.1.1
|
||||
distributed-ucxx-cu12==0.48.0
|
||||
distro==1.9.0
|
||||
dlib==19.24.6
|
||||
dm-tree==0.1.10
|
||||
docstring_parser==0.18.0
|
||||
docutils==0.21.2
|
||||
dopamine_rl==4.1.2
|
||||
duckdb==1.3.2
|
||||
earthengine-api==1.7.22
|
||||
easydict==1.13
|
||||
editdistance==0.8.1
|
||||
eerepr==0.1.2
|
||||
einops==0.8.2
|
||||
en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl#sha256=1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85
|
||||
entrypoints==0.4
|
||||
esda==2.9.0
|
||||
et_xmlfile==2.0.0
|
||||
etils==1.14.0
|
||||
etuples==0.3.10
|
||||
Farama-Notifications==0.0.4
|
||||
fastai==2.8.7
|
||||
fastapi==0.136.1
|
||||
fastcore==1.12.42
|
||||
fastdownload==0.0.7
|
||||
fastjsonschema==2.21.2
|
||||
fastlite==0.2.4
|
||||
fastprogress==1.1.5
|
||||
fasttransform==0.0.2
|
||||
ffmpy==1.0.0
|
||||
filelock==3.29.0
|
||||
fiona==1.10.1
|
||||
firebase-admin==6.9.0
|
||||
Flask==3.1.3
|
||||
flatbuffers==25.12.19
|
||||
flax==0.11.2
|
||||
folium==0.20.0
|
||||
fonttools==4.62.1
|
||||
fqdn==1.5.1
|
||||
frozendict==2.4.7
|
||||
frozenlist==1.8.0
|
||||
fsspec==2025.3.0
|
||||
future==1.0.0
|
||||
gast==0.7.0
|
||||
gcsfs==2025.3.0
|
||||
GDAL==3.8.4
|
||||
gdown==5.2.2
|
||||
geemap==0.37.2
|
||||
geocoder==1.38.1
|
||||
geographiclib==2.1
|
||||
geopandas==1.1.3
|
||||
geopy==2.4.1
|
||||
giddy==2.3.6
|
||||
gin-config==0.5.0
|
||||
gitdb==4.0.12
|
||||
GitPython==3.1.47
|
||||
glob2==0.7
|
||||
google==3.0.0
|
||||
google-adk==1.29.0
|
||||
google-ai-generativelanguage==0.6.15
|
||||
google-api-core==2.30.3
|
||||
google-api-python-client==2.194.0
|
||||
google-auth==2.47.0
|
||||
google-auth-httplib2==0.3.1
|
||||
google-auth-oauthlib==1.3.1
|
||||
google-cloud-aiplatform==1.148.1
|
||||
google-cloud-appengine-logging==1.9.0
|
||||
google-cloud-audit-log==0.5.0
|
||||
google-cloud-bigquery==3.41.0
|
||||
google-cloud-bigquery-connection==1.21.0
|
||||
google-cloud-bigquery-storage==2.37.0
|
||||
google-cloud-bigtable==2.36.0
|
||||
google-cloud-core==2.5.1
|
||||
google-cloud-dataplex==2.18.0
|
||||
google-cloud-dataproc==5.27.0
|
||||
google-cloud-datastore==2.24.0
|
||||
google-cloud-discoveryengine==0.13.12
|
||||
google-cloud-firestore==2.27.0
|
||||
google-cloud-functions==1.23.0
|
||||
google-cloud-iam==2.22.0
|
||||
google-cloud-language==2.20.0
|
||||
google-cloud-logging==3.15.0
|
||||
google-cloud-monitoring==2.30.0
|
||||
google-cloud-pubsub==2.37.0
|
||||
google-cloud-resource-manager==1.17.0
|
||||
google-cloud-secret-manager==2.27.0
|
||||
google-cloud-spanner==3.65.0
|
||||
google-cloud-speech==2.38.0
|
||||
google-cloud-storage==3.10.1
|
||||
google-cloud-trace==1.19.0
|
||||
google-cloud-translate==3.26.0
|
||||
google-colab @ file:///colabtools/dist/google_colab-1.0.0.tar.gz
|
||||
google-crc32c==1.8.0
|
||||
google-genai==1.68.0
|
||||
google-generativeai==0.8.6
|
||||
google-pasta==0.2.0
|
||||
google-resumable-media==2.8.2
|
||||
googleapis-common-protos==1.74.0
|
||||
googledrivedownloader==1.1.0
|
||||
gradio==5.50.0
|
||||
gradio_client==1.14.0
|
||||
grain==0.2.16
|
||||
graphviz==0.21
|
||||
greenlet==3.4.0
|
||||
groovy==0.1.2
|
||||
grpc-google-iam-v1==0.14.4
|
||||
grpc-interceptor==0.15.4
|
||||
grpcio==1.80.0
|
||||
grpcio-status==1.71.2
|
||||
grpclib==0.4.9
|
||||
gspread==6.2.1
|
||||
gspread-dataframe==4.0.0
|
||||
gym==0.25.2
|
||||
gym-notices==0.1.0
|
||||
gymnasium==1.3.0
|
||||
h11==0.16.0
|
||||
h2==4.3.0
|
||||
h5netcdf==1.8.1
|
||||
h5py==3.16.0
|
||||
hdbscan==0.8.42
|
||||
hf-xet==1.4.3
|
||||
highspy==1.14.0
|
||||
holidays==0.95
|
||||
holoviews==1.22.1
|
||||
hpack==4.1.0
|
||||
html5lib==1.1
|
||||
httpcore==1.0.9
|
||||
httpimport==1.4.1
|
||||
httplib2==0.31.2
|
||||
httptools==0.7.1
|
||||
httpx==0.28.1
|
||||
httpx-sse==0.4.3
|
||||
huggingface_hub==1.11.0
|
||||
humanize==4.15.0
|
||||
hyperframe==6.1.0
|
||||
hyperopt==0.2.7
|
||||
ibis-framework==9.5.0
|
||||
idna==3.13
|
||||
ImageIO==2.37.3
|
||||
imageio-ffmpeg==0.6.0
|
||||
imagesize==2.0.0
|
||||
imbalanced-learn==0.14.1
|
||||
immutabledict==4.3.1
|
||||
importlib_metadata==8.7.1
|
||||
importlib_resources==7.1.0
|
||||
imutils==0.5.4
|
||||
inequality==1.1.2
|
||||
inflect==7.5.0
|
||||
iniconfig==2.3.0
|
||||
intel-cmplr-lib-ur==2025.3.3
|
||||
intel-openmp==2025.3.3
|
||||
ipyevents==2.0.4
|
||||
ipyfilechooser==0.6.0
|
||||
ipykernel==6.17.1
|
||||
ipyleaflet==0.20.0
|
||||
ipyparallel==8.8.0
|
||||
ipython==7.34.0
|
||||
ipython-genutils==0.2.0
|
||||
ipython-sql==0.5.0
|
||||
ipywidgets==7.7.1
|
||||
isoduration==20.11.0
|
||||
itsdangerous==2.2.0
|
||||
jaraco.classes==3.4.0
|
||||
jaraco.context==6.1.2
|
||||
jaraco.functools==4.4.0
|
||||
jax==0.7.2
|
||||
jax-cuda12-pjrt==0.7.2
|
||||
jax-cuda12-plugin==0.7.2
|
||||
jaxlib==0.7.2
|
||||
jeepney==0.9.0
|
||||
jieba==0.42.1
|
||||
Jinja2==3.1.6
|
||||
jiter==0.14.0
|
||||
joblib==1.5.3
|
||||
jsonpatch==1.33
|
||||
jsonpickle==4.1.1
|
||||
jsonpointer==3.1.1
|
||||
jsonschema==4.26.0
|
||||
jsonschema-specifications==2025.9.1
|
||||
jupyter-console==6.6.3
|
||||
jupyter-events==0.12.1
|
||||
jupyter-leaflet==0.20.0
|
||||
jupyter_client==7.4.9
|
||||
jupyter_core==5.9.1
|
||||
jupyter_kernel_gateway @ git+https://github.com/googlecolab/kernel_gateway@b134e9945df25c2dcb98ade9129399be10788671
|
||||
jupyter_server==2.14.0
|
||||
jupyter_server_terminals==0.5.4
|
||||
jupyterlab_pygments==0.3.0
|
||||
jupyterlab_widgets==3.0.16
|
||||
jupytext==1.19.1
|
||||
kaggle==2.0.2
|
||||
kagglehub==1.0.0
|
||||
kagglesdk==0.1.20
|
||||
keras==3.13.2
|
||||
keras-hub==0.26.0
|
||||
keras-nlp==0.26.0
|
||||
keyring==25.7.0
|
||||
keyrings.google-artifactregistry-auth==1.1.2
|
||||
kiwisolver==1.5.0
|
||||
langchain==1.2.15
|
||||
langchain-core==1.3.1
|
||||
langgraph==1.1.9
|
||||
langgraph-checkpoint==4.0.2
|
||||
langgraph-prebuilt==1.0.10
|
||||
langgraph-sdk==0.3.13
|
||||
langsmith==0.7.34
|
||||
lark==1.3.1
|
||||
launchpadlib==1.10.16
|
||||
lazr.restfulclient==0.14.4
|
||||
lazr.uri==1.0.6
|
||||
lazy-loader==0.5
|
||||
libclang==18.1.1
|
||||
libcudf-cu12==26.2.1
|
||||
libcugraph-cu12==26.2.0
|
||||
libcuml-cu12==26.2.0
|
||||
libcuvs-cu12==26.2.0
|
||||
libkvikio-cu12==26.2.0
|
||||
libpysal==4.14.1
|
||||
libraft-cu12==26.2.0
|
||||
librmm-cu12==26.2.0
|
||||
librosa==0.11.0
|
||||
libucx-cu12==1.19.0
|
||||
libucxx-cu12==0.48.0
|
||||
lightgbm==4.6.0
|
||||
linkify-it-py==2.1.0
|
||||
llvmlite==0.43.0
|
||||
locket==1.0.0
|
||||
logical-unification==0.4.7
|
||||
lxml==6.1.0
|
||||
Mako==1.3.11
|
||||
mapclassify==2.10.0
|
||||
Markdown==3.10.2
|
||||
markdown-it-py==4.0.0
|
||||
MarkupSafe==3.0.3
|
||||
matplotlib==3.10.0
|
||||
matplotlib-inline==0.2.1
|
||||
matplotlib-venn==1.1.2
|
||||
mcp==1.27.0
|
||||
mdit-py-plugins==0.5.0
|
||||
mdurl==0.1.2
|
||||
mgwr==2.2.1
|
||||
miniKanren==1.0.5
|
||||
missingno==0.5.2
|
||||
mistune==3.2.0
|
||||
mizani==0.13.5
|
||||
mkl==2025.3.1
|
||||
ml_dtypes==0.5.4
|
||||
mlxtend==0.23.4
|
||||
mmh3==5.2.1
|
||||
momepy==0.11.0
|
||||
more-itertools==10.8.0
|
||||
moviepy==1.0.3
|
||||
mpmath==1.3.0
|
||||
msgpack==1.1.2
|
||||
multidict==6.7.1
|
||||
multipledispatch==1.0.0
|
||||
multiprocess==0.70.16
|
||||
multitasking==0.0.13
|
||||
murmurhash==1.0.15
|
||||
music21==9.9.1
|
||||
namex==0.1.0
|
||||
narwhals==2.20.0
|
||||
natsort==8.4.0
|
||||
nbclassic==1.3.3
|
||||
nbclient==0.10.4
|
||||
nbconvert==7.17.1
|
||||
nbformat==5.10.4
|
||||
ndindex==1.10.1
|
||||
nest-asyncio==1.6.0
|
||||
networkx==3.6.1
|
||||
nibabel==5.4.2
|
||||
nltk==3.9.1
|
||||
notebook==6.5.7
|
||||
notebook_shim==0.2.4
|
||||
numba==0.60.0
|
||||
numba-cuda==0.22.2
|
||||
numexpr==2.14.1
|
||||
numpy==2.0.2
|
||||
nvidia-cublas-cu12==12.8.4.1
|
||||
nvidia-cuda-cccl-cu12==12.9.27
|
||||
nvidia-cuda-cupti-cu12==12.8.90
|
||||
nvidia-cuda-nvcc-cu12==12.8.93
|
||||
nvidia-cuda-nvrtc-cu12==12.8.93
|
||||
nvidia-cuda-runtime-cu12==12.8.90
|
||||
nvidia-cudnn-cu12==9.10.2.21
|
||||
nvidia-cufft-cu12==11.3.3.83
|
||||
nvidia-cufile-cu12==1.13.1.3
|
||||
nvidia-curand-cu12==10.3.9.90
|
||||
nvidia-cusolver-cu12==11.7.3.90
|
||||
nvidia-cusparse-cu12==12.5.8.93
|
||||
nvidia-cusparselt-cu12==0.7.1
|
||||
nvidia-libnvcomp-cu12==5.1.0.21
|
||||
nvidia-ml-py==13.595.45
|
||||
nvidia-nccl-cu12==2.27.5
|
||||
nvidia-nvimgcodec-cu12==0.7.0.11
|
||||
nvidia-nvjitlink-cu12==12.8.93
|
||||
nvidia-nvshmem-cu12==3.4.5
|
||||
nvidia-nvtx-cu12==12.8.90
|
||||
nvtx==0.2.15
|
||||
nx-cugraph-cu12 @ https://pypi.nvidia.com/nx-cugraph-cu12/nx_cugraph_cu12-26.2.0-py3-none-any.whl
|
||||
oauth2client==4.1.3
|
||||
oauthlib==3.3.1
|
||||
omegaconf==2.3.0
|
||||
onemkl-license==2025.3.1
|
||||
openai==2.32.0
|
||||
opencv-contrib-python==4.13.0.92
|
||||
opencv-python==4.13.0.92
|
||||
opencv-python-headless==4.13.0.92
|
||||
openpyxl==3.1.5
|
||||
opentelemetry-api==1.38.0
|
||||
opentelemetry-exporter-gcp-logging==1.11.0a0
|
||||
opentelemetry-exporter-gcp-monitoring==1.11.0a0
|
||||
opentelemetry-exporter-gcp-trace==1.11.0
|
||||
opentelemetry-exporter-otlp-proto-common==1.38.0
|
||||
opentelemetry-exporter-otlp-proto-http==1.38.0
|
||||
opentelemetry-proto==1.38.0
|
||||
opentelemetry-resourcedetector-gcp==1.11.0a0
|
||||
opentelemetry-sdk==1.38.0
|
||||
opentelemetry-semantic-conventions==0.59b0
|
||||
opt_einsum==3.4.0
|
||||
optax==0.2.8
|
||||
optree==0.19.0
|
||||
orbax-checkpoint==0.11.36
|
||||
orjson==3.11.8
|
||||
ormsgpack==1.12.2
|
||||
osqp==1.1.1
|
||||
overrides==7.7.0
|
||||
packaging==26.1
|
||||
pandas==2.2.2
|
||||
pandas-datareader==0.10.0
|
||||
pandas-gbq==0.30.0
|
||||
pandas-stubs==2.2.2.240909
|
||||
pandocfilters==1.5.1
|
||||
panel==1.8.10
|
||||
param==2.3.3
|
||||
parso==0.8.6
|
||||
parsy==2.2
|
||||
partd==1.4.2
|
||||
patsy==1.0.2
|
||||
peewee==4.0.5
|
||||
peft==0.19.1
|
||||
pexpect==4.9.0
|
||||
pickleshare==0.7.5
|
||||
pillow==11.3.0
|
||||
pip==24.1.2
|
||||
platformdirs==4.9.6
|
||||
plotly==5.24.1
|
||||
plotnine==0.14.5
|
||||
pluggy==1.6.0
|
||||
plum-dispatch==2.8.0
|
||||
pointpats==2.5.5
|
||||
polars==1.35.2
|
||||
polars-runtime-32==1.35.2
|
||||
pooch==1.9.0
|
||||
portpicker==1.5.2
|
||||
preshed==3.0.13
|
||||
prettytable==3.17.0
|
||||
proglog==0.1.12
|
||||
progressbar2==4.5.0
|
||||
prometheus_client==0.25.0
|
||||
promise==2.3
|
||||
prompt_toolkit==3.0.52
|
||||
propcache==0.4.1
|
||||
prophet==1.3.0
|
||||
proto-plus==1.27.2
|
||||
protobuf==5.29.6
|
||||
psutil==5.9.5
|
||||
psycopg2==2.9.12
|
||||
psygnal==0.15.1
|
||||
ptyprocess==0.7.0
|
||||
PuLP==3.3.0
|
||||
py-cpuinfo==9.0.0
|
||||
py4j==0.10.9.9
|
||||
pyarrow==18.1.0
|
||||
pyasn1==0.6.3
|
||||
pyasn1_modules==0.4.2
|
||||
pycairo==1.29.0
|
||||
pycocotools==2.0.11
|
||||
pycparser==3.0
|
||||
pycryptodomex==3.23.0
|
||||
pydantic==2.12.3
|
||||
pydantic-settings==2.14.0
|
||||
pydantic_core==2.41.4
|
||||
pydata-google-auth==1.9.1
|
||||
pydot==4.0.1
|
||||
pydotplus==2.0.2
|
||||
PyDrive2==1.21.3
|
||||
pydub==0.25.1
|
||||
pyerfa==2.0.1.5
|
||||
pygame==2.6.1
|
||||
pygit2==1.19.2
|
||||
Pygments==2.20.0
|
||||
PyGObject==3.48.2
|
||||
pyiceberg==0.11.1
|
||||
PyJWT==2.12.1
|
||||
pylibcudf-cu12==26.2.1
|
||||
pylibcugraph-cu12==26.2.0
|
||||
pylibraft-cu12==26.2.0
|
||||
pymc==5.28.4
|
||||
pynndescent==0.6.0
|
||||
pyogrio==0.12.1
|
||||
pyomo==6.10.0
|
||||
PyOpenGL==3.1.10
|
||||
pyOpenSSL==24.2.1
|
||||
pyparsing==3.3.2
|
||||
pyperclip==1.11.0
|
||||
pyproj==3.7.2
|
||||
pyroaring==1.0.4
|
||||
pysal==25.7
|
||||
pyshp==3.0.3
|
||||
PySocks==1.7.1
|
||||
pyspark==4.0.2
|
||||
pytensor==2.38.2
|
||||
pytest==8.4.2
|
||||
python-apt==0.0.0
|
||||
python-box==7.4.1
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.2.2
|
||||
python-fasthtml==0.12.50
|
||||
python-json-logger==4.1.0
|
||||
python-louvain==0.16
|
||||
python-multipart==0.0.26
|
||||
python-slugify==8.0.4
|
||||
python-snappy==0.7.3
|
||||
python-utils==3.9.1
|
||||
pytz==2025.2
|
||||
pyviz_comms==3.0.6
|
||||
PyWavelets==1.9.0
|
||||
PyYAML==6.0.3
|
||||
pyzmq==26.2.1
|
||||
quantecon==0.11.2
|
||||
raft-dask-cu12==26.2.0
|
||||
rapids-dask-dependency==26.2.0
|
||||
rapids-logger==0.2.3
|
||||
rasterio==1.5.0
|
||||
rasterstats==0.20.0
|
||||
ratelim==0.1.6
|
||||
referencing==0.37.0
|
||||
regex==2025.11.3
|
||||
requests==2.32.4
|
||||
requests-oauthlib==2.0.0
|
||||
requests-toolbelt==1.0.0
|
||||
requirements-parser==0.9.0
|
||||
rfc3339-validator==0.1.4
|
||||
rfc3986-validator==0.1.1
|
||||
rfc3987-syntax==1.1.0
|
||||
rich==13.9.4
|
||||
rmm-cu12==26.2.0
|
||||
roman-numerals==4.1.0
|
||||
roman-numerals-py==4.1.0
|
||||
rpds-py==0.30.0
|
||||
rpy2==3.5.17
|
||||
rsa==4.9.1
|
||||
rtree==1.4.1
|
||||
ruff==0.15.11
|
||||
safehttpx==0.1.7
|
||||
safetensors==0.7.0
|
||||
scikit-image==0.25.2
|
||||
scikit-learn==1.6.1
|
||||
scipy==1.16.3
|
||||
scooby==0.11.2
|
||||
scs==3.2.11
|
||||
seaborn==0.13.2
|
||||
SecretStorage==3.5.0
|
||||
segregation==2.5.4
|
||||
semantic-version==2.10.0
|
||||
Send2Trash==2.1.0
|
||||
sentence-transformers==5.4.1
|
||||
sentencepiece==0.2.1
|
||||
sentry-sdk==2.58.0
|
||||
setuptools==75.2.0
|
||||
shap==0.51.0
|
||||
shapely==2.1.2
|
||||
shellingham==1.5.4
|
||||
simple-parsing==0.1.8
|
||||
simplejson==4.1.0
|
||||
simsimd==6.5.16
|
||||
six==1.17.0
|
||||
sklearn-compat==0.1.5
|
||||
sklearn-pandas==2.2.0
|
||||
slicer==0.0.8
|
||||
smart_open==7.6.0
|
||||
smmap==5.0.3
|
||||
sniffio==1.3.1
|
||||
snowballstemmer==3.0.1
|
||||
sortedcontainers==2.4.0
|
||||
soundfile==0.13.1
|
||||
soupsieve==2.8.3
|
||||
soxr==1.0.0
|
||||
spacy==3.8.14
|
||||
spacy-legacy==3.0.12
|
||||
spacy-loggers==1.0.5
|
||||
spaghetti==1.7.6
|
||||
spanner-graph-notebook==1.1.10
|
||||
spglm==1.1.0
|
||||
Sphinx==8.2.3
|
||||
sphinxcontrib-applehelp==2.0.0
|
||||
sphinxcontrib-devhelp==2.0.0
|
||||
sphinxcontrib-htmlhelp==2.1.0
|
||||
sphinxcontrib-jsmath==1.0.1
|
||||
sphinxcontrib-qthelp==2.0.0
|
||||
sphinxcontrib-serializinghtml==2.0.0
|
||||
spint==1.0.7
|
||||
splot==1.1.7
|
||||
spopt==0.7.0
|
||||
spreg==1.9.0
|
||||
SQLAlchemy==2.0.49
|
||||
sqlalchemy-spanner==1.17.3
|
||||
sqlglot==25.20.2
|
||||
sqlparse==0.5.5
|
||||
srsly==2.5.3
|
||||
sse-starlette==3.3.4
|
||||
stanio==0.5.1
|
||||
starlette==0.52.1
|
||||
statsmodels==0.14.6
|
||||
strictyaml==1.7.3
|
||||
stringzilla==4.6.0
|
||||
stumpy==1.13.0
|
||||
sympy==1.14.0
|
||||
tables==3.10.2
|
||||
tabulate==0.9.0
|
||||
tbb==2022.3.1
|
||||
tblib==3.2.2
|
||||
tcmlib==1.4.1
|
||||
tenacity==9.1.4
|
||||
tensorboard==2.20.0
|
||||
tensorboard-data-server==0.7.2
|
||||
tensorflow==2.20.0
|
||||
tensorflow-datasets==4.9.9
|
||||
tensorflow-hub==0.16.1
|
||||
tensorflow-metadata==1.17.3
|
||||
tensorflow-probability==0.25.0
|
||||
tensorflow-text==2.20.1
|
||||
tensorstore==0.1.82
|
||||
termcolor==3.3.0
|
||||
terminado==0.18.1
|
||||
text-unidecode==1.3
|
||||
textblob==0.19.0
|
||||
tf-slim==1.1.0
|
||||
tf_keras==2.20.0
|
||||
thinc==8.3.13
|
||||
threadpoolctl==3.6.0
|
||||
tifffile==2026.4.11
|
||||
tiktoken==0.12.0
|
||||
timm==1.0.26
|
||||
tinycss2==1.4.0
|
||||
tobler==0.14.0
|
||||
tokenizers==0.22.2
|
||||
toml==0.10.2
|
||||
tomlkit==0.13.3
|
||||
toolz==0.12.1
|
||||
torch==2.10.0+cu128
|
||||
torchao==0.10.0
|
||||
torchaudio==2.10.0+cu128
|
||||
torchcodec==0.10.0+cu128
|
||||
torchdata==0.11.0
|
||||
torchsummary==1.5.1
|
||||
torchtune==0.6.1
|
||||
torchvision==0.25.0+cu128
|
||||
tornado==6.5.1
|
||||
tqdm==4.67.3
|
||||
traitlets==5.7.1
|
||||
traittypes==0.2.3
|
||||
transformers==5.0.0
|
||||
treelite==4.7.0
|
||||
treescope==0.1.10
|
||||
triton==3.6.0
|
||||
tsfresh==0.21.1
|
||||
tweepy==4.16.0
|
||||
typeguard==4.5.1
|
||||
typer==0.24.2
|
||||
typer-slim==0.24.0
|
||||
types-pytz==2026.1.1.20260408
|
||||
types-setuptools==82.0.0.20260408
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
tzdata==2026.1
|
||||
tzlocal==5.3.1
|
||||
uc-micro-py==2.0.0
|
||||
ucxx-cu12==0.48.0
|
||||
umap-learn==0.5.12
|
||||
umf==1.0.3
|
||||
uri-template==1.3.0
|
||||
uritemplate==4.2.0
|
||||
urllib3==2.5.0
|
||||
uuid_utils==0.14.1
|
||||
uvicorn==0.46.0
|
||||
uvloop==0.22.1
|
||||
vega-datasets==0.9.0
|
||||
wadllib==1.3.6
|
||||
wandb==0.26.1
|
||||
wasabi==1.1.3
|
||||
watchdog==6.0.0
|
||||
watchfiles==1.1.1
|
||||
wcwidth==0.6.0
|
||||
weasel==1.0.0
|
||||
webcolors==25.10.0
|
||||
webencodings==0.5.1
|
||||
websocket-client==1.9.0
|
||||
websockets==15.0.1
|
||||
Werkzeug==3.1.8
|
||||
wheel==0.47.0
|
||||
widgetsnbextension==3.6.10
|
||||
wordcloud==1.9.6
|
||||
wrapt==2.1.2
|
||||
xarray==2025.12.0
|
||||
xarray-einstats==0.10.0
|
||||
xgboost==3.2.0
|
||||
xlrd==2.0.2
|
||||
xxhash==3.6.0
|
||||
xyzservices==2026.3.0
|
||||
yarl==1.23.0
|
||||
ydf==0.15.0
|
||||
ydf_tf==2.20.0
|
||||
yellowbrick==1.5
|
||||
yfinance==0.2.66
|
||||
zict==3.0.0
|
||||
zipp==3.23.1
|
||||
zstandard==0.25.0
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"_comment": "Maps Colab GPU runtime pinned wheels to CPU equivalents for ubuntu-latest CI smoke jobs. The Colab GPU image ships +cu128 builds that won't install on a CPU-only runner; this map either rewrites the spec to a CPU wheel from https://download.pytorch.org/whl/cpu or falls back to module-spoof for packages with no CPU build.",
|
||||
"rewrite": {
|
||||
"torch": {
|
||||
"from_local_version": "+cu128",
|
||||
"to_index_url": "https://download.pytorch.org/whl/cpu"
|
||||
},
|
||||
"torchvision": {
|
||||
"from_local_version": "+cu128",
|
||||
"to_index_url": "https://download.pytorch.org/whl/cpu"
|
||||
},
|
||||
"torchaudio": {
|
||||
"from_local_version": "+cu128",
|
||||
"to_index_url": "https://download.pytorch.org/whl/cpu"
|
||||
}
|
||||
},
|
||||
"module_spoof": {
|
||||
"torchcodec": "no CPU wheel published; smoke job sys.modules-stubs torchcodec before importing unsloth"
|
||||
},
|
||||
"skip": [
|
||||
"nvidia-cublas-cu12",
|
||||
"nvidia-cuda-cupti-cu12",
|
||||
"nvidia-cuda-nvrtc-cu12",
|
||||
"nvidia-cuda-runtime-cu12",
|
||||
"nvidia-cudnn-cu12",
|
||||
"nvidia-cufft-cu12",
|
||||
"nvidia-curand-cu12",
|
||||
"nvidia-cusolver-cu12",
|
||||
"nvidia-cusparse-cu12",
|
||||
"nvidia-cusparselt-cu12",
|
||||
"nvidia-nccl-cu12",
|
||||
"nvidia-nvjitlink-cu12",
|
||||
"nvidia-nvtx-cu12",
|
||||
"triton"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ensure keyword arguments use spaces around '=', prune redundant pass statements,
|
||||
drop the blank line after a short indented import block, merge adjacent same-line
|
||||
string literals, normalize def-signature magic commas (pre-ruff) so a def with
|
||||
>= 3 params and a default goes one-per-line while everything else stays
|
||||
collapsible, and collapse a short multi-line assert onto one line (pre-ruff) by
|
||||
stripping the magic trailing comma that holds it open."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import tokenize
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _atomic_write_text(path: Path, data: str, encoding: str) -> None:
|
||||
"""Write ``data`` to ``path`` atomically via same-dir tmp + fsync + os.replace,
|
||||
so a crash mid-write leaves either the old or full new content, never a truncation."""
|
||||
dirpath = str(path.parent) or "."
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".kwargs_fix.", dir=dirpath)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding=encoding) as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_path, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def enforce_spacing(text: str) -> tuple[str, bool]:
|
||||
"""Return updated text with keyword '=' padded by spaces, plus change flag."""
|
||||
lines = text.splitlines(keepends=True)
|
||||
if not lines:
|
||||
return text, False
|
||||
|
||||
offsets: dict[int, int] = defaultdict(int)
|
||||
changed = False
|
||||
|
||||
reader = io.StringIO(text).readline
|
||||
for token in tokenize.generate_tokens(reader):
|
||||
if token.type != tokenize.OP or token.string != "=":
|
||||
continue
|
||||
|
||||
line_index = token.start[0] - 1
|
||||
col = token.start[1] + offsets[line_index]
|
||||
|
||||
if line_index < 0 or line_index >= len(lines):
|
||||
continue
|
||||
|
||||
line = lines[line_index]
|
||||
if col >= len(line) or line[col] != "=":
|
||||
continue
|
||||
|
||||
line_changed = False
|
||||
|
||||
# Insert a space before '=' when missing and not preceded by whitespace.
|
||||
if col > 0 and line[col - 1] not in {" ", "\t"}:
|
||||
line = f"{line[:col]} {line[col:]}"
|
||||
offsets[line_index] += 1
|
||||
col += 1
|
||||
line_changed = True
|
||||
changed = True
|
||||
|
||||
# Insert a space after '=' when missing and not followed by whitespace or newline.
|
||||
next_index = col + 1
|
||||
if next_index < len(line) and line[next_index] not in {" ", "\t", "\n", "\r"}:
|
||||
line = f"{line[:next_index]} {line[next_index:]}"
|
||||
offsets[line_index] += 1
|
||||
line_changed = True
|
||||
changed = True
|
||||
|
||||
if line_changed:
|
||||
lines[line_index] = line
|
||||
|
||||
if not changed:
|
||||
return text, False
|
||||
|
||||
return "".join(lines), True
|
||||
|
||||
|
||||
def remove_redundant_passes(text: str) -> tuple[str, bool]:
|
||||
"""Drop pass statements that share a block with other executable code."""
|
||||
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
return text, False
|
||||
|
||||
redundant: list[ast.Pass] = []
|
||||
|
||||
def visit(node: ast.AST) -> None:
|
||||
for attr in ("body", "orelse", "finalbody"):
|
||||
value = getattr(node, attr, None)
|
||||
if not isinstance(value, list) or len(value) <= 1:
|
||||
continue
|
||||
for stmt in value:
|
||||
if isinstance(stmt, ast.Pass):
|
||||
redundant.append(stmt)
|
||||
for stmt in value:
|
||||
if isinstance(stmt, ast.AST):
|
||||
visit(stmt)
|
||||
handlers = getattr(node, "handlers", None)
|
||||
if handlers:
|
||||
for handler in handlers:
|
||||
visit(handler)
|
||||
|
||||
visit(tree)
|
||||
|
||||
if not redundant:
|
||||
return text, False
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
changed = False
|
||||
|
||||
for node in sorted(redundant, key=lambda item: (item.lineno, item.col_offset), reverse=True):
|
||||
start = node.lineno - 1
|
||||
end = (node.end_lineno or node.lineno) - 1
|
||||
if start >= len(lines):
|
||||
continue
|
||||
changed = True
|
||||
if start == end:
|
||||
line = lines[start]
|
||||
col_start = node.col_offset
|
||||
col_end = node.end_col_offset or (col_start + 4)
|
||||
segment = line[:col_start] + line[col_end:]
|
||||
lines[start] = segment if segment.strip() else ""
|
||||
continue
|
||||
|
||||
# Fall-back for unexpected multi-line 'pass'.
|
||||
prefix = lines[start][: node.col_offset]
|
||||
lines[start] = prefix if prefix.strip() else ""
|
||||
for idx in range(start + 1, end):
|
||||
lines[idx] = ""
|
||||
suffix = lines[end][(node.end_col_offset or 0) :]
|
||||
lines[end] = suffix
|
||||
|
||||
# Normalise to ensure lines end with newlines except at EOF.
|
||||
result_lines: list[str] = []
|
||||
for index, line in enumerate(lines):
|
||||
if not line:
|
||||
continue
|
||||
if index < len(lines) - 1 and not line.endswith("\n"):
|
||||
result_lines.append(f"{line}\n")
|
||||
else:
|
||||
result_lines.append(line)
|
||||
|
||||
return "".join(result_lines), changed
|
||||
|
||||
|
||||
def remove_blank_after_short_import(text: str) -> tuple[str, bool]:
|
||||
"""Drop blank line(s) after an import block in a small nested suite.
|
||||
|
||||
In an indented suite of <= 3 statements (never module level), when consecutive
|
||||
imports are followed across blank lines (nothing else) by another statement,
|
||||
remove those blanks. A comment in the gap blocks the rule. Removing blank lines
|
||||
never changes the AST.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
except SyntaxError:
|
||||
return text, False
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
import_types = (ast.Import, ast.ImportFrom)
|
||||
drop: set[int] = set() # 1-based physical line numbers to delete
|
||||
|
||||
def suites_of(node: ast.AST) -> list[list[ast.stmt]]:
|
||||
if isinstance(node, ast.Module):
|
||||
return [] # module-level import spacing is left alone
|
||||
out: list[list[ast.stmt]] = []
|
||||
for attr in ("body", "orelse", "finalbody"):
|
||||
val = getattr(node, attr, None)
|
||||
if isinstance(val, list) and val and all(isinstance(s, ast.stmt) for s in val):
|
||||
out.append(val)
|
||||
return out
|
||||
|
||||
for node in ast.walk(tree):
|
||||
for suite in suites_of(node):
|
||||
if len(suite) > 3: # only small blocks
|
||||
continue
|
||||
i = 0
|
||||
while i < len(suite):
|
||||
if not isinstance(suite[i], import_types):
|
||||
i += 1
|
||||
continue
|
||||
j = i
|
||||
while j + 1 < len(suite) and isinstance(suite[j + 1], import_types):
|
||||
j += 1
|
||||
if j + 1 < len(suite): # an import block followed by another statement
|
||||
last_imp, nxt = suite[j], suite[j + 1]
|
||||
gap = range((last_imp.end_lineno or last_imp.lineno) + 1, nxt.lineno)
|
||||
nums = [n for n in gap if 1 <= n <= len(lines)]
|
||||
if nums and all(lines[n - 1].strip() == "" for n in nums):
|
||||
drop.update(nums)
|
||||
i = j + 1
|
||||
|
||||
if not drop:
|
||||
return text, False
|
||||
kept = [ln for idx, ln in enumerate(lines, start=1) if idx not in drop]
|
||||
return "".join(kept), True
|
||||
|
||||
|
||||
_STRING_TRIVIA = (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT)
|
||||
|
||||
|
||||
_DEF_MIN_PARAMS_FOR_MULTILINE = 3 # signatures with < this many params stay one line
|
||||
|
||||
|
||||
def _def_specs_by_line(tree: ast.AST) -> dict[int, tuple[int, bool]]:
|
||||
"""Map each def keyword line to (param count, has-any-default).
|
||||
|
||||
``*`` / ``/`` markers aren't counted. A default exists if any positional default
|
||||
is present or any keyword-only default is not ``None`` (``None`` in ``kw_defaults``
|
||||
means a required keyword-only arg).
|
||||
"""
|
||||
out: dict[int, tuple[int, bool]] = {}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
a = node.args
|
||||
count = (
|
||||
len(a.posonlyargs)
|
||||
+ len(a.args)
|
||||
+ len(a.kwonlyargs)
|
||||
+ (1 if a.vararg else 0)
|
||||
+ (1 if a.kwarg else 0)
|
||||
)
|
||||
has_default = bool(a.defaults) or any(d is not None for d in a.kw_defaults)
|
||||
out[node.lineno] = (count, has_default)
|
||||
return out
|
||||
|
||||
|
||||
def normalize_def_trailing_comma(text: str) -> tuple[str, bool]:
|
||||
"""Force a def signature one-per-line iff >= 3 params AND a default; else collapsible.
|
||||
|
||||
A qualifying signature gets a magic trailing comma added (ruff wraps it
|
||||
one-per-line); every other signature has its trailing comma stripped so ruff
|
||||
collapses it when it fits. Def parameter lists only, never call sites or
|
||||
collection literals. Run BEFORE ruff format. Never changes the AST (re-checked).
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
toks = list(tokenize.generate_tokens(io.StringIO(text).readline))
|
||||
except (tokenize.TokenError, IndentationError, SyntaxError):
|
||||
return text, False
|
||||
|
||||
specs = _def_specs_by_line(tree)
|
||||
n = len(toks)
|
||||
edits: list[tuple[int, int, str]] = [] # (row, col, "del" | "ins")
|
||||
i = 0
|
||||
while i < n:
|
||||
t = toks[i]
|
||||
if t.type == tokenize.NAME and t.string == "def" and t.start[0] in specs:
|
||||
cnt, has_default = specs[t.start[0]]
|
||||
force_multiline = cnt >= _DEF_MIN_PARAMS_FOR_MULTILINE and has_default
|
||||
j = i + 1
|
||||
while j < n and not (toks[j].type == tokenize.OP and toks[j].string == "("):
|
||||
if toks[j].type == tokenize.NEWLINE:
|
||||
break
|
||||
j += 1
|
||||
if j < n and toks[j].type == tokenize.OP and toks[j].string == "(":
|
||||
depth = 0
|
||||
k = j
|
||||
while k < n:
|
||||
tk = toks[k]
|
||||
if tk.type == tokenize.OP and tk.string == "(":
|
||||
depth += 1
|
||||
elif tk.type == tokenize.OP and tk.string == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
m = k - 1
|
||||
while m > j and toks[m].type in _STRING_TRIVIA:
|
||||
m -= 1
|
||||
last = toks[m]
|
||||
has_comma = last.type == tokenize.OP and last.string == ","
|
||||
empty = m == j # nothing between ( and )
|
||||
if force_multiline and not has_comma and not empty:
|
||||
edits.append((last.end[0], last.end[1], "ins"))
|
||||
elif not force_multiline and has_comma:
|
||||
edits.append((last.start[0], last.start[1], "del"))
|
||||
break
|
||||
k += 1
|
||||
i = k + 1
|
||||
continue
|
||||
i += 1
|
||||
|
||||
if not edits:
|
||||
return text, False
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
for row, col, kind in sorted(edits, reverse=True):
|
||||
ln = lines[row - 1]
|
||||
if kind == "del":
|
||||
if col < len(ln) and ln[col] == ",":
|
||||
lines[row - 1] = ln[:col] + ln[col + 1 :]
|
||||
else: # ins
|
||||
lines[row - 1] = ln[:col] + "," + ln[col:]
|
||||
out = "".join(lines)
|
||||
try:
|
||||
if ast.dump(ast.parse(out)) != ast.dump(ast.parse(text)):
|
||||
return text, False
|
||||
except SyntaxError:
|
||||
return text, False
|
||||
return out, True
|
||||
|
||||
|
||||
def _split_string_token(s: str) -> tuple[str, str, str] | None:
|
||||
"""Split a string literal source into (prefix, quote, body).
|
||||
|
||||
``prefix`` is the letters before the opening quote, ``quote`` the delimiter,
|
||||
``body`` everything between. ``None`` if not a recognizable string literal.
|
||||
"""
|
||||
i = 0
|
||||
while i < len(s) and s[i] not in ("'", '"'):
|
||||
i += 1
|
||||
if i >= len(s):
|
||||
return None
|
||||
prefix, rest = s[:i], s[i:]
|
||||
for q in ('"""', "'''", '"', "'"):
|
||||
if rest.startswith(q) and rest.endswith(q) and len(rest) >= 2 * len(q):
|
||||
return prefix, q, rest[len(q) : len(rest) - len(q)]
|
||||
return None
|
||||
|
||||
|
||||
# A "piece" is one string literal in source: a plain STRING token, or a whole
|
||||
# f-string spanning FSTRING_START..FSTRING_END. (kind, (row, col0), (row, col1), raw)
|
||||
def _string_pieces(
|
||||
toks: list[tokenize.TokenInfo], lines: list[str]
|
||||
) -> list[tuple[str, tuple[int, int], tuple[int, int], str | None]]:
|
||||
pieces: list[tuple[str, tuple[int, int], tuple[int, int], str | None]] = []
|
||||
n = len(toks)
|
||||
|
||||
def raw_of(start: tuple[int, int], end: tuple[int, int]) -> str | None:
|
||||
if start[0] != end[0]: # only single-physical-line pieces are mergeable
|
||||
return None
|
||||
return lines[start[0] - 1][start[1] : end[1]]
|
||||
|
||||
i = 0
|
||||
while i < n:
|
||||
t = toks[i]
|
||||
if t.type == tokenize.STRING:
|
||||
pieces.append(("str", t.start, t.end, raw_of(t.start, t.end)))
|
||||
i += 1
|
||||
elif t.type == tokenize.FSTRING_START:
|
||||
depth = 0
|
||||
j = i
|
||||
while j < n: # walk to the matching FSTRING_END (f-strings can nest)
|
||||
if toks[j].type == tokenize.FSTRING_START:
|
||||
depth += 1
|
||||
elif toks[j].type == tokenize.FSTRING_END:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
j += 1
|
||||
end = toks[j].end
|
||||
pieces.append(("f", t.start, end, raw_of(t.start, end)))
|
||||
i = j + 1
|
||||
else:
|
||||
pieces.append(("other", t.start, t.end, None))
|
||||
i += 1
|
||||
return pieces
|
||||
|
||||
|
||||
def _merge_string_run(pieces: list[tuple[str, str]]) -> str | None:
|
||||
"""Merge a run of adjacent string pieces into one literal's source text.
|
||||
|
||||
``pieces`` is ``(kind, raw_source)`` with kind ``"str"`` or ``"f"``. Bytes are
|
||||
left side-by-side (``None``); a run with no f-string merges plain/raw/unicode
|
||||
sharing one prefix+quote by body concatenation; a run mixing an f-string with a
|
||||
plain string (no bytes, no raw) folds into one f-string with plain braces escaped.
|
||||
Runs of only f-strings are left alone. Caller re-checks the AST and drops a
|
||||
differing change, so subtle cases are caught.
|
||||
"""
|
||||
parsed = []
|
||||
for kind, raw in pieces:
|
||||
pqb = _split_string_token(raw)
|
||||
if pqb is None:
|
||||
return None
|
||||
prefix, quote, body = pqb
|
||||
if "b" in prefix.lower():
|
||||
return None # bytes: leave side-by-side
|
||||
parsed.append((kind, prefix, quote, body))
|
||||
if len({p[2] for p in parsed}) != 1:
|
||||
return None # mixed quote style: not a safe textual merge
|
||||
quote = parsed[0][2]
|
||||
if not any(p[0] == "f" for p in parsed):
|
||||
# No f-string: merge plain/raw/unicode sharing one prefix by concatenation.
|
||||
if len({p[1].lower() for p in parsed}) != 1:
|
||||
return None
|
||||
return f"{parsed[0][1]}{quote}{''.join(p[3] for p in parsed)}{quote}"
|
||||
# f-string fold only when a plain string is glued onto an f-string; a run of
|
||||
# only f-strings is left side-by-side (folding long ones would force ruff to
|
||||
# re-wrap the surrounding statement).
|
||||
if all(p[0] == "f" for p in parsed):
|
||||
return None
|
||||
# raw mixed with f is too subtle (backslash + brace escaping) -> skip.
|
||||
if any("r" in p[1].lower() for p in parsed):
|
||||
return None
|
||||
body = "".join(
|
||||
b if kind == "f" else b.replace("{", "{{").replace("}", "}}")
|
||||
for kind, _pfx, _q, b in parsed
|
||||
)
|
||||
return f"f{quote}{body}{quote}"
|
||||
|
||||
|
||||
_LINE_LENGTH = 100 # ruff line-length; an f-fold must not push a statement past it
|
||||
|
||||
|
||||
def _enclosing_stmt(tree: ast.AST, row: int) -> ast.stmt | None:
|
||||
"""The innermost statement whose physical-line span contains ``row``."""
|
||||
best: tuple[ast.stmt, int] | None = None
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.stmt):
|
||||
lo = node.lineno
|
||||
hi = node.end_lineno or lo
|
||||
if lo <= row <= hi and (best is None or hi - lo < best[1]):
|
||||
best = (node, hi - lo)
|
||||
return best[0] if best else None
|
||||
|
||||
|
||||
def _fold_collapses(
|
||||
tree: ast.AST, lines: list[str], row: int, c0: int, c1: int, merged: str
|
||||
) -> bool:
|
||||
"""Whether an f-string fold at ``row[c0:c1]`` -> ``merged`` is safe to apply.
|
||||
|
||||
Only ``assert`` wraps awkwardly when a message folds (ruff parenthesizes the
|
||||
condition once it no longer fits one line); every other construct wraps
|
||||
acceptably so is always allowed. An ``assert`` fold is allowed only if already
|
||||
one line, or its estimated folded one-line length fits the line length.
|
||||
"""
|
||||
stmt = _enclosing_stmt(tree, row)
|
||||
if not isinstance(stmt, ast.Assert):
|
||||
return True
|
||||
lo, hi = stmt.lineno, stmt.end_lineno or stmt.lineno
|
||||
if lo == hi:
|
||||
return True
|
||||
seg = []
|
||||
for k in range(lo, hi + 1):
|
||||
ln = lines[k - 1].rstrip("\n")
|
||||
if k == row:
|
||||
ln = ln[:c0] + merged + ln[c1:]
|
||||
seg.append(ln)
|
||||
indent = len(seg[0]) - len(seg[0].lstrip())
|
||||
# Conservative over-estimate: join continuation lines with a single space
|
||||
# (ruff joins bracketed wraps with none), so borderline cases skip the fold.
|
||||
joined = " ".join(s.strip() for s in seg)
|
||||
return indent + len(joined) <= _LINE_LENGTH
|
||||
|
||||
|
||||
def merge_adjacent_string_literals(text: str) -> tuple[str, bool]:
|
||||
"""Merge adjacent string literals on ONE physical line into a single literal.
|
||||
|
||||
Plain/raw/unicode runs merge by concatenation; an f-string + plain string folds
|
||||
into one f-string (plain braces escaped) only while the statement still fits one
|
||||
line. Runs of only f-strings, and bytes, are left side-by-side. The file AST is
|
||||
re-checked and a differing change dropped, so meaning never changes.
|
||||
"""
|
||||
try:
|
||||
toks = list(tokenize.generate_tokens(io.StringIO(text).readline))
|
||||
tree = ast.parse(text)
|
||||
except (tokenize.TokenError, IndentationError, SyntaxError):
|
||||
return text, False
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
pieces = _string_pieces(toks, lines)
|
||||
|
||||
# Group consecutive mergeable pieces (str/f, single line, same physical line).
|
||||
runs: list[list[tuple[str, tuple[int, int], tuple[int, int], str]]] = []
|
||||
cur: list[tuple[str, tuple[int, int], tuple[int, int], str]] = []
|
||||
for kind, start, end, raw in pieces:
|
||||
if kind in ("str", "f") and raw is not None:
|
||||
if cur and cur[-1][2][0] != start[0]:
|
||||
if len(cur) >= 2:
|
||||
runs.append(cur)
|
||||
cur = []
|
||||
cur.append((kind, start, end, raw))
|
||||
else:
|
||||
if len(cur) >= 2:
|
||||
runs.append(cur)
|
||||
cur = []
|
||||
if len(cur) >= 2:
|
||||
runs.append(cur)
|
||||
if not runs:
|
||||
return text, False
|
||||
|
||||
edits = []
|
||||
for run in runs:
|
||||
merged = _merge_string_run([(kind, raw) for kind, _s, _e, raw in run])
|
||||
if merged is None:
|
||||
continue
|
||||
row, c0, c1 = run[0][1][0], run[0][1][1], run[-1][2][1]
|
||||
# An f-string fold must not push its statement onto extra lines; a plain
|
||||
# concatenation always collapses cleanly so it skips this check.
|
||||
if any(kind == "f" for kind, _s, _e, _r in run) and not _fold_collapses(
|
||||
tree, lines, row, c0, c1, merged
|
||||
):
|
||||
continue
|
||||
edits.append((row, c0, c1, merged))
|
||||
if not edits:
|
||||
return text, False
|
||||
|
||||
for row, c0, c1, repl in sorted(edits, key=lambda e: (e[0], e[1]), reverse=True):
|
||||
ln = lines[row - 1]
|
||||
lines[row - 1] = ln[:c0] + repl + ln[c1:]
|
||||
out = "".join(lines)
|
||||
try:
|
||||
if ast.dump(ast.parse(text)) != ast.dump(ast.parse(out)):
|
||||
return text, False
|
||||
except SyntaxError:
|
||||
return text, False
|
||||
return out, True
|
||||
|
||||
|
||||
def collapse_short_asserts(text: str) -> tuple[str, bool]:
|
||||
"""Collapse a multi-line ``assert`` onto one line when it would fit.
|
||||
|
||||
When the statement's estimated one-line length fits, strip the magic trailing
|
||||
commas (comma before a closer) holding it open so ruff rejoins it. Run BEFORE
|
||||
ruff format. Skips asserts with a comment (would oscillate). Stripping is
|
||||
non-semantic except for a one-element tuple; AST is re-checked and changing
|
||||
asserts left alone.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(text)
|
||||
toks = list(tokenize.generate_tokens(io.StringIO(text).readline))
|
||||
except (tokenize.TokenError, IndentationError, SyntaxError):
|
||||
return text, False
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
multiline = [
|
||||
(n.lineno, n.end_lineno)
|
||||
for n in ast.walk(tree)
|
||||
if isinstance(n, ast.Assert) and (n.end_lineno or n.lineno) > n.lineno
|
||||
]
|
||||
if not multiline:
|
||||
return text, False
|
||||
|
||||
comment_rows = {t.start[0] for t in toks if t.type == tokenize.COMMENT}
|
||||
|
||||
targets = [] # (lo, hi) spans whose one-line form fits and have no comment
|
||||
for lo, hi in multiline:
|
||||
if any(lo <= r <= hi for r in comment_rows):
|
||||
continue # a comment would keep ruff multi-line -> never collapses
|
||||
seg = [lines[k].rstrip("\n") for k in range(lo - 1, hi)]
|
||||
indent = len(seg[0]) - len(seg[0].lstrip())
|
||||
# Over-estimate (join with a space; keep the comma) so a "fits" verdict
|
||||
# is always at least as long as ruff's real one-line output -> no fight.
|
||||
if indent + len(" ".join(s.strip() for s in seg)) <= _LINE_LENGTH:
|
||||
targets.append((lo, hi))
|
||||
if not targets:
|
||||
return text, False
|
||||
|
||||
# Trailing commas (a ',' whose next significant token is a closer), grouped
|
||||
# by the target assert they belong to.
|
||||
sig = [t for t in toks if t.type not in _STRING_TRIVIA]
|
||||
by_target: dict[tuple[int, int], list[tuple[int, int]]] = defaultdict(list)
|
||||
for i, t in enumerate(sig):
|
||||
if t.type == tokenize.OP and t.string == ",":
|
||||
nxt = sig[i + 1] if i + 1 < len(sig) else None
|
||||
if nxt and nxt.type == tokenize.OP and nxt.string in (")", "]", "}"):
|
||||
for lo, hi in targets:
|
||||
if lo <= t.start[0] <= hi:
|
||||
by_target[(lo, hi)].append(t.start)
|
||||
break
|
||||
if not by_target:
|
||||
return text, False
|
||||
|
||||
base_dump = ast.dump(tree)
|
||||
working = lines[:]
|
||||
changed = False
|
||||
for positions in by_target.values(): # apply per assert; skip any that break AST
|
||||
trial = working[:]
|
||||
for row, col in sorted(positions, reverse=True):
|
||||
ln = trial[row - 1]
|
||||
if col < len(ln) and ln[col] == ",":
|
||||
trial[row - 1] = ln[:col] + ln[col + 1 :]
|
||||
try:
|
||||
if ast.dump(ast.parse("".join(trial))) == base_dump:
|
||||
working, changed = trial, True
|
||||
except SyntaxError:
|
||||
pass
|
||||
return ("".join(working), True) if changed else (text, False)
|
||||
|
||||
|
||||
def process_file(path: Path, pre: bool = False) -> bool:
|
||||
try:
|
||||
with tokenize.open(path) as handle:
|
||||
original = handle.read()
|
||||
encoding = handle.encoding
|
||||
except (OSError, SyntaxError) as exc: # SyntaxError from tokenize on invalid python
|
||||
print(f"Failed to read {path}: {exc}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if pre:
|
||||
# Pre-ruff: normalize def-signature magic commas (>=3 params + a default
|
||||
# add so ruff forces one-per-line; everything else strips so ruff
|
||||
# collapses), and strip the magic trailing comma from a short multi-line
|
||||
# assert so ruff joins it onto one line. Everything else runs post-ruff.
|
||||
updated, normalized = normalize_def_trailing_comma(original)
|
||||
updated, collapsed = collapse_short_asserts(updated)
|
||||
if normalized or collapsed:
|
||||
_atomic_write_text(path, updated, encoding)
|
||||
return True
|
||||
return False
|
||||
|
||||
updated, changed = enforce_spacing(original)
|
||||
updated, blanked = remove_blank_after_short_import(updated)
|
||||
updated, merged = merge_adjacent_string_literals(updated)
|
||||
updated, removed = remove_redundant_passes(updated)
|
||||
if changed or blanked or merged or removed:
|
||||
_atomic_write_text(path, updated, encoding)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("files", nargs="+", help="Python files to fix")
|
||||
parser.add_argument(
|
||||
"--pre",
|
||||
action="store_true",
|
||||
help="pre-ruff pass: normalize def-signature commas + collapse short multi-line asserts",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
touched: list[Path] = []
|
||||
self_path = Path(__file__).resolve()
|
||||
|
||||
for entry in args.files:
|
||||
path = Path(entry)
|
||||
# Skip modifying this script to avoid self-edit loops.
|
||||
if path.resolve() == self_path:
|
||||
continue
|
||||
if not path.exists() or path.is_dir():
|
||||
continue
|
||||
if process_file(path, pre=args.pre):
|
||||
touched.append(path)
|
||||
|
||||
if touched:
|
||||
for path in touched:
|
||||
print(f"Adjusted kwarg spacing in {path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/bin/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
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================
|
||||
# Gemma 4 MLX — One-command setup + inference
|
||||
#
|
||||
# Supply-chain hardening: the uv installer payload is pinned by
|
||||
# SHA-256. Rotate by running:
|
||||
# curl -sSLf https://astral.sh/uv/install.sh | shasum -a 256
|
||||
# and updating _UV_INSTALLER_SHA256 below.
|
||||
# ============================================================
|
||||
#
|
||||
# Usage:
|
||||
# bash install_gemma4_mlx.sh [--venv-dir DIR]
|
||||
#
|
||||
# This script:
|
||||
# 1. Creates a Python virtual environment
|
||||
# 2. Installs uv, mlx-vlm, transformers
|
||||
# ============================================================
|
||||
|
||||
# ── Output style (inspired by unsloth/install.sh) ─────────────
|
||||
RULE=""
|
||||
_rule_i=0
|
||||
while [ "$_rule_i" -lt 52 ]; do
|
||||
RULE="${RULE}─"
|
||||
_rule_i=$((_rule_i + 1))
|
||||
done
|
||||
|
||||
if [ -n "${NO_COLOR:-}" ]; then
|
||||
C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST=
|
||||
elif [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; then
|
||||
_ESC="$(printf '\033')"
|
||||
C_TITLE="${_ESC}[38;5;117m"
|
||||
C_DIM="${_ESC}[38;5;245m"
|
||||
C_OK="${_ESC}[38;5;108m"
|
||||
C_WARN="${_ESC}[38;5;136m"
|
||||
C_ERR="${_ESC}[91m"
|
||||
C_RST="${_ESC}[0m"
|
||||
else
|
||||
C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST=
|
||||
fi
|
||||
|
||||
step() { printf " ${C_DIM}%-18.18s${C_RST}${3:-$C_OK}%s${C_RST}\n" "$1" "$2"; }
|
||||
substep() { printf " ${C_DIM}%-18s${2:-$C_DIM}%s${C_RST}\n" "" "$1"; }
|
||||
fail() { step "error" "$1" "$C_ERR"; exit 1; }
|
||||
|
||||
# ── Parse flags ───────────────────────────────────────────────
|
||||
VENV_DIR=""
|
||||
_next_is_venv=false
|
||||
|
||||
for arg in "$@"; do
|
||||
if [ "$_next_is_venv" = true ]; then
|
||||
VENV_DIR="$arg"
|
||||
_next_is_venv=false
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--venv-dir) _next_is_venv=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Default venv location
|
||||
if [ -z "$VENV_DIR" ]; then
|
||||
VENV_DIR="$HOME/.unsloth/unsloth_gemma4_mlx"
|
||||
fi
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────
|
||||
echo ""
|
||||
printf " ${C_TITLE}%s${C_RST}\n" "💎 Gemma 4 MLX Installer"
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||
|
||||
# ── Platform check ────────────────────────────────────────────
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
fail "MLX requires macOS with Apple Silicon. Detected: $(uname)"
|
||||
fi
|
||||
|
||||
_ARCH=$(uname -m)
|
||||
if [ "$_ARCH" != "arm64" ]; then
|
||||
step "warning" "Apple Silicon recommended (detected: $_ARCH)" "$C_WARN"
|
||||
fi
|
||||
|
||||
step "platform" "macOS ($_ARCH)"
|
||||
|
||||
# ── Detect Python ─────────────────────────────────────────────
|
||||
PYTHON=""
|
||||
for _candidate in python3.12 python3.11 python3.13 python3; do
|
||||
if command -v "$_candidate" >/dev/null 2>&1; then
|
||||
PYTHON="$_candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PYTHON" ]; then
|
||||
fail "Python 3 not found. Install via: brew install python@3.12"
|
||||
fi
|
||||
|
||||
_PY_VERSION=$("$PYTHON" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')")
|
||||
step "python" "$PYTHON ($_PY_VERSION)"
|
||||
|
||||
# ── Create virtual environment ────────────────────────────────
|
||||
if [ -x "$VENV_DIR/bin/python" ]; then
|
||||
step "venv" "using existing environment"
|
||||
substep "$VENV_DIR"
|
||||
else
|
||||
step "venv" "creating virtual environment"
|
||||
substep "$VENV_DIR"
|
||||
mkdir -p "$(dirname "$VENV_DIR")"
|
||||
"$PYTHON" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
# ── Install uv ───────────────────────────────────────────────
|
||||
_UV_INSTALLER_SHA256="48cd5aca5d5671a3b3d5f61538cc8622e4434af63319115159990d8b0dd02416"
|
||||
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
step "uv" "installing uv package manager..."
|
||||
_uv_tmp=$(mktemp)
|
||||
curl -LsSf "https://astral.sh/uv/install.sh" -o "$_uv_tmp"
|
||||
_uv_actual=$(shasum -a 256 "$_uv_tmp" | awk '{print $1}')
|
||||
if [ "$_uv_actual" != "$_UV_INSTALLER_SHA256" ]; then
|
||||
rm -f "$_uv_tmp"
|
||||
fail "uv installer SHA-256 mismatch: got $_uv_actual expected $_UV_INSTALLER_SHA256 (refusing to execute)"
|
||||
fi
|
||||
sh "$_uv_tmp" </dev/null >/dev/null 2>&1
|
||||
rm -f "$_uv_tmp"
|
||||
if [ -f "$HOME/.local/bin/env" ]; then
|
||||
. "$HOME/.local/bin/env"
|
||||
fi
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
substep "done"
|
||||
else
|
||||
step "uv" "found $(uv --version 2>/dev/null || echo 'uv')"
|
||||
fi
|
||||
|
||||
_VENV_PY="$VENV_DIR/bin/python"
|
||||
|
||||
# ── Install dependencies ──────────────────────────────────────
|
||||
step "install" "installing mlx-vlm..."
|
||||
uv pip install --python "$_VENV_PY" -q mlx-vlm
|
||||
substep "done"
|
||||
|
||||
step "install" "installing transformers>=5.5.0..."
|
||||
if uv pip install --python "$_VENV_PY" -q "transformers>=5.5.0" 2>/dev/null; then
|
||||
substep "installed from PyPI"
|
||||
else
|
||||
substep "PyPI install failed (Python <3.10?), trying GitHub..."
|
||||
if uv pip install --python "$_VENV_PY" -q "git+https://github.com/huggingface/transformers.git@v5.5-release" 2>/dev/null; then
|
||||
substep "installed from huggingface/transformers v5.5-release"
|
||||
else
|
||||
step "warning" "could not install transformers>=5.5.0" "$C_WARN"
|
||||
substep "tried: PyPI, huggingface/transformers v5.5-release"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Verify installation ──────────────────────────────────────
|
||||
if "$_VENV_PY" -c "import mlx_vlm"; then
|
||||
substep "mlx-vlm verified"
|
||||
else
|
||||
fail "Installation verification failed."
|
||||
fi
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────
|
||||
echo ""
|
||||
printf " ${C_TITLE}%s${C_RST}\n" "Gemma 4 MLX installed!"
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||
step "available models" "unsloth/gemma-4-E2B-it-UD-MLX-4bit"
|
||||
substep "unsloth/gemma-4-E4B-it-UD-MLX-4bit"
|
||||
substep "unsloth/gemma-4-26b-a4b-it-UD-MLX-4bit"
|
||||
substep "unsloth/gemma-4-31b-it-UD-MLX-4bit"
|
||||
echo ""
|
||||
step "venv activate" "source ${VENV_DIR}/bin/activate"
|
||||
echo ""
|
||||
step "text chat" "python -m mlx_vlm.chat --model unsloth/gemma-4-E2B-it-UD-MLX-4bit"
|
||||
echo ""
|
||||
step "vision chat" "python -m mlx_vlm.chat --model unsloth/gemma-4-31b-it-UD-MLX-4bit"
|
||||
substep "Use /image path/to/image.jpg to load an image"
|
||||
echo ""
|
||||
step "gradio UI" "python -m mlx_vlm.chat_ui --model unsloth/gemma-4-31b-it-UD-MLX-4bit"
|
||||
echo ""
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||