Merge remote-tracking branch 'origin/main' into dbb-merge-main
# Conflicts: # .gitignore # tests/studio/run_real_mlx_smoke.py
This commit is contained in:
commit
c0abb0ab6a
805 changed files with 87144 additions and 17270 deletions
75
.github/workflows/consolidated-tests-ci.yml
vendored
75
.github/workflows/consolidated-tests-ci.yml
vendored
|
|
@ -2204,12 +2204,13 @@ jobs:
|
|||
pip install -e "$RUNNER_TEMP/unsloth-zoo" --no-deps
|
||||
pip show unsloth_zoo
|
||||
|
||||
- name: llama.cpp install via unsloth_zoo.llama_cpp + `llama-cli --help` smoke
|
||||
- name: llama.cpp install via unsloth_zoo.llama_cpp + CLI `--help` smoke
|
||||
# Exercise the canonical `unsloth_zoo.llama_cpp.install_llama_cpp`
|
||||
# flow that GGUF export uses at runtime: clone ggml-org/llama.cpp
|
||||
# into ~/.unsloth/llama.cpp, build the LLAMA_CPP_TARGETS list
|
||||
# (llama-quantize, llama-cli, llama-mtmd-cli, llama-gguf-split,
|
||||
# llama-server) via cmake, then run `llama-cli --help`.
|
||||
# llama-server) via cmake, then run `--help` on whichever CLI
|
||||
# inference binary the build actually produced.
|
||||
#
|
||||
# This replaces the previous "download upstream prebuilt zip"
|
||||
# approach, which silently exited 0 with the message
|
||||
|
|
@ -2218,6 +2219,18 @@ jobs:
|
|||
# matched their current asset names). The build path is the same
|
||||
# one Unsloth users hit in production via `model.save_pretrained_gguf`.
|
||||
#
|
||||
# We do NOT hard-require `llama-cli` specifically: upstream
|
||||
# ggml-org/llama.cpp moved the cli/server/ui targets behind the
|
||||
# `LLAMA_BUILD_SERVER` cmake option (tools/CMakeLists.txt) and the
|
||||
# set of binaries that survive a given checkout drifts over time
|
||||
# (e.g. a recent build root shipped llama-server + llama-quantize
|
||||
# + llama-diffusion-cli but no llama-cli). The durable contract is
|
||||
# "install_llama_cpp produced a working CLI inference binary AND a
|
||||
# working quantizer", so we --help-probe the first of
|
||||
# llama-cli / llama-mtmd-cli / llama-server that exists. If a
|
||||
# future llama.cpp restores llama-cli it is first in the list and
|
||||
# is preferred, so this stays backwards compatible.
|
||||
#
|
||||
# Wall-time budget: ~3-5 min cold, dominated by cmake build of
|
||||
# 5 targets on the runner's 4 cores. Apt-package install is
|
||||
# handled by `install_llama_cpp` itself via its
|
||||
|
|
@ -2252,8 +2265,9 @@ jobs:
|
|||
print(f"Build targets: {LLAMA_CPP_TARGETS}")
|
||||
# install_llama_cpp returns (quantizer_path, converter_script_path).
|
||||
# The quantizer's directory is the `llama.cpp` install root, which
|
||||
# also holds llama-cli after build/bin/llama-* gets copied up
|
||||
# (llama_cpp.py:867-871).
|
||||
# also holds the CLI inference binaries after build/bin/llama-* gets
|
||||
# copied up (llama_cpp.py:1450-1454; on Windows they stay in
|
||||
# build/bin/Release/).
|
||||
quantizer, converter = install_llama_cpp(print_output=True)
|
||||
assert quantizer and os.path.exists(quantizer), (
|
||||
f"install_llama_cpp returned quantizer={quantizer!r} but file missing"
|
||||
|
|
@ -2262,25 +2276,54 @@ jobs:
|
|||
f"install_llama_cpp returned converter={converter!r} but missing"
|
||||
)
|
||||
install_root = os.path.dirname(quantizer)
|
||||
cli = os.path.join(install_root, "llama-cli")
|
||||
assert os.path.exists(cli), (
|
||||
f"llama-cli not found at {cli!r} after build. Build root contents: "
|
||||
f"{sorted(p for p in os.listdir(install_root) if p.startswith('llama-'))[:20]}"
|
||||
)
|
||||
assert os.access(cli, os.X_OK), f"{cli!r} not executable"
|
||||
# `llama-cli --help` exits non-zero on some builds; the contract
|
||||
# is that recognizable help text appears on stdout/stderr.
|
||||
is_windows = sys.platform == "win32"
|
||||
exe = ".exe" if is_windows else ""
|
||||
# Search both the copied-up root and the Windows build/bin/Release/
|
||||
# location the quantizer might already live in.
|
||||
search_dirs = [install_root]
|
||||
win_release = os.path.join(install_root, "build", "bin", "Release")
|
||||
if win_release not in search_dirs:
|
||||
search_dirs.append(win_release)
|
||||
# Any of these proves a working llama.cpp CLI inference binary was
|
||||
# built. Order = preference: llama-cli is canonical (restored first
|
||||
# if upstream brings it back), then the multimodal CLI, then the
|
||||
# server (always built whenever cli would be, behind LLAMA_BUILD_SERVER).
|
||||
cli_names = [f"llama-cli{exe}", f"llama-mtmd-cli{exe}", f"llama-server{exe}"]
|
||||
cli = None
|
||||
cli_name = None
|
||||
for name in cli_names:
|
||||
for d in search_dirs:
|
||||
candidate = os.path.join(d, name)
|
||||
if os.path.exists(candidate) and (is_windows or os.access(candidate, os.X_OK)):
|
||||
cli, cli_name = candidate, name
|
||||
break
|
||||
if cli is not None:
|
||||
break
|
||||
if cli is None:
|
||||
found = []
|
||||
for d in search_dirs:
|
||||
if os.path.isdir(d):
|
||||
found += [p for p in os.listdir(d) if p.startswith("llama-")]
|
||||
raise AssertionError(
|
||||
f"No CLI inference binary ({', '.join(cli_names)}) found after "
|
||||
f"build in {search_dirs}. Build root contents: {sorted(set(found))[:20]}"
|
||||
)
|
||||
print(f"Using CLI inference binary: {cli_name} -> {cli}")
|
||||
# `--help` exits non-zero on some builds; the contract is that
|
||||
# recognizable help text appears on stdout/stderr. llama-server
|
||||
# exposes a different flag set than llama-cli, so accept its
|
||||
# tokens too (e.g. --host / --port / "server").
|
||||
proc = subprocess.run(
|
||||
[cli, "--help"], capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
combined = (proc.stdout or "") + (proc.stderr or "")
|
||||
print("--- llama-cli --help (first 30 lines) ---")
|
||||
print(f"--- {cli_name} --help (first 30 lines) ---")
|
||||
print("\n".join(combined.splitlines()[:30]))
|
||||
assert any(
|
||||
tok in combined.lower()
|
||||
for tok in ("usage", "--help", "--model", "-m,")
|
||||
for tok in ("usage", "--help", "--model", "-m,", "--host", "--port", "server")
|
||||
), (
|
||||
f"llama-cli --help produced no recognizable help text. "
|
||||
f"{cli_name} --help produced no recognizable help text. "
|
||||
f"exit={proc.returncode}\nstdout: {proc.stdout[:400]!r}\n"
|
||||
f"stderr: {proc.stderr[:400]!r}"
|
||||
)
|
||||
|
|
@ -2296,7 +2339,7 @@ jobs:
|
|||
f"stderr: {q.stderr[:400]!r}"
|
||||
)
|
||||
print(
|
||||
f"\nOK: install_llama_cpp produced a working llama-cli at {cli} "
|
||||
f"\nOK: install_llama_cpp produced a working {cli_name} at {cli} "
|
||||
f"and llama-quantize at {quantizer}."
|
||||
)
|
||||
PY
|
||||
|
|
|
|||
611
.github/workflows/local-agent-guides-ci.yml
vendored
Normal file
611
.github/workflows/local-agent-guides-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
# 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/connect.py (the in-repo source of truth -- there
|
||||
# is no docs/ tree). Wherever connect.py has a recipe we drive the agent
|
||||
# via `unsloth connect <agent> --no-launch` and execute what it prints, so
|
||||
# the test self-updates against connect.py and catches silent recipe drift.
|
||||
#
|
||||
# Source-of-truth files this workflow guards:
|
||||
# unsloth_cli/commands/connect.py the `unsloth connect <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 connect.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 connect` flow produced no/garbled output.
|
||||
#
|
||||
# Agents covered (6): claude, codex, hermes, openclaw, opencode, pi.
|
||||
# - claude/codex/hermes/openclaw/opencode have a connect.py recipe.
|
||||
# - pi has NO `unsloth connect pi` command in connect.py at HEAD; it is
|
||||
# driven by a hand-written recipe and the matrix cell asserts that the
|
||||
# missing connect recipe is the (known) reason, so the day connect.py
|
||||
# grows a `pi` command this cell flips to the self-updating path.
|
||||
|
||||
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 connect <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 and is below hermes' 64K context floor). 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 connect.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 connect.py and assert a reply ──────────
|
||||
# For the 5 agents with a connect.py recipe we run
|
||||
# `unsloth connect <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 connect (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.
|
||||
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
|
||||
grep -rlF "$UNSLOTH_API_KEY" logs 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.
|
||||
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
|
||||
grep -rlF "$UNSLOTH_API_KEY" logs 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 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.
|
||||
if [ -n "${UNSLOTH_API_KEY:-}" ]; then
|
||||
grep -rlF "$UNSLOTH_API_KEY" logs 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
|
||||
15
.github/workflows/mlx-ci.yml
vendored
15
.github/workflows/mlx-ci.yml
vendored
|
|
@ -241,7 +241,8 @@ jobs:
|
|||
# non-zero binary exit is an Unsloth/Studio bug.
|
||||
- name: Studio prebuilt llama.cpp install + GGUF inference (Mac M1)
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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 || '' }}
|
||||
# install_llama_prebuilt.py hits the GitHub releases API to
|
||||
# resolve the asset URL. Anonymous calls share the runner-IP
|
||||
# rate-limit bucket and 403 quickly -- pass the workflow's
|
||||
|
|
@ -332,7 +333,8 @@ jobs:
|
|||
# train_metrics.json so we can detect regressions across CI runs.
|
||||
- name: MLX export round-trip — TRAIN + SAVE 3 formats
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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 || '' }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
mkdir -p mlx_workdir
|
||||
|
|
@ -348,7 +350,8 @@ jobs:
|
|||
# the saved dir.
|
||||
- name: MLX export round-trip — RELOAD LoRA (fresh process)
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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 || '' }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
python tests/studio/run_real_mlx_smoke.py reload \
|
||||
|
|
@ -357,7 +360,8 @@ jobs:
|
|||
|
||||
- name: MLX export round-trip — RELOAD merged_16bit (fresh process)
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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 || '' }}
|
||||
UNSLOTH_COMPILE_DISABLE: '1'
|
||||
run: |
|
||||
python tests/studio/run_real_mlx_smoke.py reload \
|
||||
|
|
@ -372,7 +376,8 @@ jobs:
|
|||
# LoRA + merged_16bit assertions remain the gating signal.
|
||||
- name: MLX export round-trip — RELOAD GGUF via llama-cli (fresh process)
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
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 \
|
||||
|
|
|
|||
101
.github/workflows/release-desktop.yml
vendored
101
.github/workflows/release-desktop.yml
vendored
|
|
@ -353,7 +353,7 @@ jobs:
|
|||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
|
||||
# ── Node.js ──
|
||||
- name: Setup Node.js
|
||||
|
|
@ -406,9 +406,65 @@ jobs:
|
|||
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*$/);
|
||||
|
|
@ -438,6 +494,12 @@ jobs:
|
|||
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
|
||||
|
||||
|
|
@ -562,6 +624,33 @@ jobs:
|
|||
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'
|
||||
|
|
@ -570,6 +659,7 @@ jobs:
|
|||
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
|
||||
|
|
@ -580,9 +670,10 @@ jobs:
|
|||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
|
||||
**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 }}
|
||||
|
|
@ -611,9 +702,10 @@ jobs:
|
|||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
|
||||
**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 }}
|
||||
|
|
@ -643,9 +735,10 @@ jobs:
|
|||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` (Ubuntu/Debian) or `.AppImage` (universal).
|
||||
**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 }}
|
||||
|
|
|
|||
58
.github/workflows/security-audit.yml
vendored
58
.github/workflows/security-audit.yml
vendored
|
|
@ -434,7 +434,7 @@ jobs:
|
|||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Semgrep: design-flaw detection (catches what regex-pattern
|
||||
# scanning of malicious authors cannot — first-party logic bugs
|
||||
# scanning of malicious authors cannot, e.g. first-party logic bugs
|
||||
# like langchain-core CVE-2025-68664 dumps/dumpd injection,
|
||||
# n8n CVE-2025-68668 _pyodide.eval_code sandbox escape, marimo
|
||||
# CVE-2026-39987 unauth WebSocket).
|
||||
|
|
@ -849,10 +849,13 @@ jobs:
|
|||
grep -q "Standalone pre-install package scanner" scripts/scan_packages.py
|
||||
|
||||
- name: Scan declared + transitive Python deps
|
||||
# scan_packages.py exits 1 on CRITICAL/HIGH findings, 0 on
|
||||
# clean. We swallow the exit because the baseline isn't
|
||||
# triaged yet; surface the findings in the workflow summary.
|
||||
# Drop continue-on-error after the first clean run on main.
|
||||
# scan_packages.py exits 1 on NON-baselined CRITICAL/HIGH
|
||||
# findings, 0 otherwise. It scans code-only (docstrings and
|
||||
# comments are blanked first) and suppresses reviewed
|
||||
# known-good findings via scripts/scan_packages_baseline.json,
|
||||
# so legitimate-library noise no longer red-fails the gate.
|
||||
# The step stays advisory until SCAN_ENFORCE=1 (see env below);
|
||||
# then PIPESTATUS propagates the scanner's exit code.
|
||||
#
|
||||
# `--with-deps` walks PyPI metadata to enumerate every
|
||||
# transitive dep the declared set would install, then scans
|
||||
|
|
@ -869,6 +872,14 @@ jobs:
|
|||
# downloads in exchange for wall-clock parallelism.
|
||||
env:
|
||||
SHARD_FILES: ${{ matrix.shard.files }}
|
||||
# Enforcement switch. "1" = blocking: a non-baselined CRITICAL/HIGH
|
||||
# fails the build. scan_packages.py scans code-only (docstrings/comments
|
||||
# stripped), fetches sdist-only packages directly from PyPI (no build)
|
||||
# so every shard resolves, and honors the reviewed allowlist at
|
||||
# scripts/scan_packages_baseline.json, so only NON-baselined
|
||||
# CRITICAL/HIGH cause its exit 1. The committed baseline makes all three
|
||||
# shards exit 0 today; set this back to "0" to return to advisory.
|
||||
SCAN_ENFORCE: "1"
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p logs
|
||||
|
|
@ -884,12 +895,14 @@ jobs:
|
|||
fi
|
||||
done
|
||||
echo "::endgroup::"
|
||||
rc=0
|
||||
if [ ${#REQ_ARGS[@]} -eq 0 ]; then
|
||||
echo "[security-audit] shard ${{ matrix.shard.id }}: no PyPI specs, nothing to scan" \
|
||||
| tee "$LOG"
|
||||
else
|
||||
python scripts/scan_packages.py --with-deps "${REQ_ARGS[@]}" \
|
||||
2>&1 | tee "$LOG"
|
||||
rc=${PIPESTATUS[0]}
|
||||
fi
|
||||
{
|
||||
echo "## scan_packages :: shard ${{ matrix.shard.id }}"
|
||||
|
|
@ -897,11 +910,19 @@ jobs:
|
|||
echo "### Files in this shard"
|
||||
for f in $SHARD_FILES; do echo "- audit-reqs/$f.txt"; done
|
||||
echo
|
||||
echo "scan_packages.py exit code: $rc (enforce=$SCAN_ENFORCE)"
|
||||
echo
|
||||
echo '### Findings (tail)'
|
||||
echo '```'
|
||||
tail -200 "$LOG"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
# Advisory by default; blocking once SCAN_ENFORCE=1 and the baseline
|
||||
# is committed. PIPESTATUS is captured above so `tee` does not mask the
|
||||
# scanner's exit code.
|
||||
if [ "$SCAN_ENFORCE" = "1" ]; then
|
||||
exit "$rc"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
|
|
@ -975,24 +996,37 @@ jobs:
|
|||
python3 -c "import ast; ast.parse(open('scripts/scan_npm_packages.py').read())"
|
||||
|
||||
- name: Scan npm tarballs (declared + transitive, no install)
|
||||
# The script exits 1 on HIGH/CRITICAL findings; we capture the
|
||||
# full log and surface it in the step summary either way. It
|
||||
# never runs `npm install`, never executes anything from a
|
||||
# downloaded tarball, and only fetches from registry.npmjs.org.
|
||||
# Initially non-blocking so the baseline can settle; drop
|
||||
# continue-on-error once the baseline is clean for a week.
|
||||
# scan_npm_packages.py exits 1 on NON-baselined HIGH/CRITICAL
|
||||
# findings, 0 otherwise. It scans code-only (JS/TS comments are
|
||||
# blanked first) and honors a reviewed allowlist at
|
||||
# scripts/scan_npm_packages_baseline.json. It never runs
|
||||
# `npm install`, never executes anything from a downloaded
|
||||
# tarball, and only fetches from registry.npmjs.org. The npm
|
||||
# corpus is clean (the baseline is empty), so the gate is
|
||||
# enforcing (SCAN_ENFORCE=1) and any new finding fails the build.
|
||||
env:
|
||||
SCAN_ENFORCE: "1"
|
||||
run: |
|
||||
set -o pipefail
|
||||
set +e
|
||||
LOG=logs-scan-npm.txt
|
||||
python3 scripts/scan_npm_packages.py 2>&1 | tee "$LOG"
|
||||
rc=${PIPESTATUS[0]}
|
||||
{
|
||||
echo "## scan_npm_packages"
|
||||
echo
|
||||
echo "scan_npm_packages.py exit code: $rc (enforce=$SCAN_ENFORCE)"
|
||||
echo
|
||||
echo '### Findings (tail)'
|
||||
echo '```'
|
||||
tail -300 "$LOG"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
# Blocking: the npm corpus is clean, so any non-baselined
|
||||
# HIGH/CRITICAL is new and should fail the build. PIPESTATUS is
|
||||
# captured above so `tee` does not mask the scanner's exit code.
|
||||
if [ "$SCAN_ENFORCE" = "1" ]; then
|
||||
exit "$rc"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
|
|
|
|||
6
.github/workflows/studio-api-smoke.yml
vendored
6
.github/workflows/studio-api-smoke.yml
vendored
|
|
@ -83,7 +83,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -100,7 +101,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
|
|||
7
.github/workflows/studio-backend-ci.yml
vendored
7
.github/workflows/studio-backend-ci.yml
vendored
|
|
@ -222,9 +222,14 @@ jobs:
|
|||
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; do
|
||||
tests/sh/test_torch_constraint.sh \
|
||||
tests/sh/test_torch_flavor.sh; do
|
||||
echo "::group::$s"
|
||||
bash "$s"
|
||||
echo "::endgroup::"
|
||||
|
|
|
|||
29
.github/workflows/studio-inference-smoke.yml
vendored
29
.github/workflows/studio-inference-smoke.yml
vendored
|
|
@ -97,7 +97,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -114,7 +115,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
@ -317,7 +319,7 @@ jobs:
|
|||
timeout-minutes: 25
|
||||
env:
|
||||
# Tool calling is the highest-volume GGUF in this workflow
|
||||
# (Qwen3.5-2B at IQ3_XXS = ~890 MiB). Caching HF_HOME would
|
||||
# (Qwen3.5-2B at Q4_K_XL = ~1.28 GiB). Caching HF_HOME would
|
||||
# store xet chunks + blobs + snapshots = ~4 GiB compressed --
|
||||
# 4-5x file-size inflation, dominated by xet chunks. Use main's
|
||||
# `--local-dir gguf-cache` pattern to cache the flat .gguf only.
|
||||
|
|
@ -326,8 +328,11 @@ jobs:
|
|||
# path keeps the test off HF_HOME entirely so the cache size
|
||||
# tracks the GGUF file 1:1. The OpenAI/Anth and JSON+images
|
||||
# jobs still cover the gguf_variant resolution path.
|
||||
# Q4_K_XL, not IQ3_XXS: at IQ3_XXS this model emits malformed
|
||||
# tool calls that llama-server's peg-native parser rejects with a
|
||||
# 500. Mac/Windows already use Q4_K_XL for the same reason.
|
||||
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
|
||||
GGUF_FILE: Qwen3.5-2B-UD-IQ3_XXS.gguf
|
||||
GGUF_FILE: Qwen3.5-2B-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18889'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
|
@ -361,7 +366,8 @@ jobs:
|
|||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -377,7 +383,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
@ -772,6 +779,9 @@ jobs:
|
|||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
ss -tln | grep ":${STUDIO_PORT}" || true
|
||||
# Capture backend + llama-server logs so a 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 logs
|
||||
# Always upload so green runs are still reviewable.
|
||||
|
|
@ -784,6 +794,7 @@ jobs:
|
|||
path: |
|
||||
logs/studio.log
|
||||
logs/install.log
|
||||
logs/server-logs/
|
||||
retention-days: 7
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -838,7 +849,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -856,7 +868,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
|
|||
6
.github/workflows/studio-mac-api-smoke.yml
vendored
6
.github/workflows/studio-mac-api-smoke.yml
vendored
|
|
@ -68,7 +68,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -85,7 +86,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
|
|||
18
.github/workflows/studio-mac-inference-smoke.yml
vendored
18
.github/workflows/studio-mac-inference-smoke.yml
vendored
|
|
@ -91,7 +91,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -110,7 +111,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
@ -346,7 +348,8 @@ jobs:
|
|||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -363,7 +366,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
@ -725,7 +729,8 @@ jobs:
|
|||
# Authenticated + parallel: shared macos-14 NAT egress stalls
|
||||
# multi-GB anonymous downloads.
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -752,7 +757,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
|
|||
6
.github/workflows/studio-mac-ui-smoke.yml
vendored
6
.github/workflows/studio-mac-ui-smoke.yml
vendored
|
|
@ -68,7 +68,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -85,7 +86,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
@ -74,7 +75,8 @@ jobs:
|
|||
- name: First update should be a no-op (prebuilt already validated)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
@ -93,7 +95,8 @@ jobs:
|
|||
- name: Second update must also be a no-op
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
|
|||
2
.github/workflows/studio-tauri-smoke.yml
vendored
2
.github/workflows/studio-tauri-smoke.yml
vendored
|
|
@ -47,7 +47,7 @@ jobs:
|
|||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev \
|
||||
librsvg2-dev libxdo-dev libssl-dev patchelf
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
|
|
|||
11
.github/workflows/studio-ui-smoke.yml
vendored
11
.github/workflows/studio-ui-smoke.yml
vendored
|
|
@ -82,7 +82,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -99,7 +100,8 @@ jobs:
|
|||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
@ -276,6 +278,10 @@ jobs:
|
|||
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 --
|
||||
|
|
@ -289,6 +295,7 @@ jobs:
|
|||
logs/studio_extra.log
|
||||
logs/studio_ime.log
|
||||
logs/install.log
|
||||
logs/server-logs/
|
||||
logs/playwright
|
||||
logs/playwright_extra
|
||||
logs/playwright_ime
|
||||
|
|
|
|||
9
.github/workflows/studio-update-smoke.yml
vendored
9
.github/workflows/studio-update-smoke.yml
vendored
|
|
@ -71,7 +71,8 @@ jobs:
|
|||
# prebuilt path falls back to source build.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
@ -86,7 +87,8 @@ jobs:
|
|||
# idempotency regressed.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
@ -109,7 +111,8 @@ jobs:
|
|||
# the first one.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -124,7 +125,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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;
|
||||
|
|
|
|||
584
.github/workflows/studio-windows-inference-smoke.yml
vendored
584
.github/workflows/studio-windows-inference-smoke.yml
vendored
|
|
@ -26,6 +26,7 @@ on:
|
|||
- 'unsloth_cli/**'
|
||||
- 'install.ps1'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio_setup_ps1/**'
|
||||
- '.github/workflows/studio-windows-inference-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
|
|
@ -65,17 +66,34 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Fast GPU-free gate: parse setup.ps1 and run the Resolve-CudaToolkit unit
|
||||
# test (deferred Windows CUDA Toolkit check) before the heavy GGUF smoke.
|
||||
- name: setup.ps1 unit test (Resolve-CudaToolkit)
|
||||
# Fast GPU-free gate: parse install.ps1 + setup.ps1 and run the PowerShell
|
||||
# unit tests (CUDA-toolkit + torch-flavor helpers) before the heavy GGUF smoke.
|
||||
- name: PowerShell installer unit tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in @('install.ps1', 'studio/setup.ps1')) {
|
||||
$errs = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile(
|
||||
(Resolve-Path $f).Path, [ref]$null, [ref]$errs)
|
||||
if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 }
|
||||
Write-Host "$f parsed with no errors"
|
||||
}
|
||||
pwsh -NoProfile -File tests/studio/test_resolve_cuda_toolkit.ps1
|
||||
pwsh -NoProfile -File tests/studio/test_torch_flavor.ps1
|
||||
pwsh -NoProfile -File tests/studio/test_node_decision.ps1
|
||||
pwsh -NoProfile -File tests/studio/test_node_probe_guard.ps1
|
||||
|
||||
# uninstall.ps1: native uninstall must keep the shared unsloth.ico while a
|
||||
# WSL shortcut still references it (dual install), else that shortcut blanks.
|
||||
- name: uninstall.ps1 unit test (dual-install icon preserve)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$errs = $null
|
||||
[void][System.Management.Automation.Language.Parser]::ParseFile(
|
||||
(Resolve-Path studio/setup.ps1).Path, [ref]$null, [ref]$errs)
|
||||
(Resolve-Path scripts/uninstall.ps1).Path, [ref]$null, [ref]$errs)
|
||||
if ($errs) { $errs | ForEach-Object { $_.ToString() }; exit 1 }
|
||||
Write-Host "setup.ps1 parsed with no errors"
|
||||
pwsh -NoProfile -File tests/studio/test_resolve_cuda_toolkit.ps1
|
||||
Write-Host "uninstall.ps1 parsed with no errors"
|
||||
pwsh -NoProfile -File tests/studio/test_uninstall_dual_install_icon.ps1
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
|
|
@ -109,7 +127,8 @@ jobs:
|
|||
# described above (outcome != success).
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -161,7 +180,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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;
|
||||
|
|
@ -458,7 +478,8 @@ jobs:
|
|||
id: download-gguf
|
||||
if: steps.cache-gguf.outputs.cache-hit != 'true' || steps.cache-gguf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p gguf-cache
|
||||
|
|
@ -506,7 +527,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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;
|
||||
|
|
@ -888,7 +910,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -938,7 +961,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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;
|
||||
|
|
@ -1239,3 +1263,539 @@ jobs:
|
|||
logs/install.log
|
||||
logs/llama-server/*.log
|
||||
retention-days: 7
|
||||
|
||||
# ── folded from studio-windows-no-vs-smoke.yml: install + run with no Visual Studio ──
|
||||
no-vs-cpu:
|
||||
name: Studio install + inference without Visual Studio
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 35
|
||||
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: '18820'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
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
|
||||
run: |
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
npm install -g 'npm@^11' 2>&1 | Out-Host
|
||||
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 } catch { }
|
||||
}
|
||||
|
||||
- name: Hide Visual Studio + CMake (simulate a host with no build tools)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# Rename the Visual Studio install roots (incl. the Installer that holds
|
||||
# vswhere.exe) so Find-VsBuildTools' vswhere + filesystem scan both miss.
|
||||
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
|
||||
if (Test-Path -LiteralPath $d) {
|
||||
Rename-Item -LiteralPath $d -NewName ((Split-Path $d -Leaf) + '.vsoff')
|
||||
Write-Host "Hid VS: $d"
|
||||
}
|
||||
}
|
||||
# Surgically rename each cmake executable on PATH (not its parent dir --
|
||||
# cmake can share a dir with other shims) so Get-Command cmake fails.
|
||||
$hidden = @()
|
||||
foreach ($c in (Get-Command cmake -All -ErrorAction SilentlyContinue)) {
|
||||
if ($c.Source -and (Test-Path -LiteralPath $c.Source)) {
|
||||
Rename-Item -LiteralPath $c.Source -NewName ((Split-Path $c.Source -Leaf) + '.off')
|
||||
$hidden += $c.Source
|
||||
Write-Host "Hid cmake: $($c.Source)"
|
||||
}
|
||||
}
|
||||
("HIDDEN_CMAKE=" + ($hidden -join '|')) | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Assert Visual Studio + CMake are genuinely undetectable
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1')
|
||||
$setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1'
|
||||
foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Find-VsBuildTools')) {
|
||||
. ([scriptblock]::Create((Get-FunctionSource -Path $setup -Name $fn)))
|
||||
}
|
||||
$vs = Find-VsBuildTools
|
||||
if ($vs) { Write-Error "Find-VsBuildTools still detects VS: $($vs.Generator) @ $($vs.InstallPath)"; exit 1 }
|
||||
if (Get-Command cmake -ErrorAction SilentlyContinue) { Write-Error "cmake is still on PATH"; exit 1 }
|
||||
if (Get-Command cl.exe -ErrorAction SilentlyContinue) { Write-Error "cl.exe is still on PATH"; exit 1 }
|
||||
Write-Host "Confirmed: no Visual Studio, no cmake, no cl.exe."
|
||||
|
||||
- name: PyTorch CPU wheel installs and imports (no Visual Studio)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
python -c "import torch; print('torch', torch.__version__, 'cuda?', torch.cuda.is_available())"
|
||||
|
||||
- name: Install Studio (--local, --no-torch) with no build tools present
|
||||
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
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
|
||||
|
||||
- name: Assert prebuilt used AND no build tools were installed
|
||||
run: |
|
||||
LLAMA_DIR=~/.unsloth/llama.cpp
|
||||
INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json"
|
||||
BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe"
|
||||
fail=0
|
||||
if grep -q "falling back to source build" logs/install.log; then
|
||||
echo "::error::install.ps1 fell back to source-build llama.cpp without VS."; fail=1
|
||||
fi
|
||||
# The deferred build-tool installs must NOT run on the prebuilt path.
|
||||
for pat in "Kitware.CMake" "Microsoft.VisualStudio.2022.BuildTools" "installing via winget"; do
|
||||
if grep -qi "$pat" logs/install.log; then
|
||||
echo "::error::unexpected build-tool install on the prebuilt path: '$pat'"; fail=1
|
||||
fi
|
||||
done
|
||||
[ -f "$INFO" ] || { echo "::error::no UNSLOTH_PREBUILT_INFO.json"; ls -la "$LLAMA_DIR" || true; fail=1; }
|
||||
[ -f "$BIN" ] || { echo "::error::no llama-server.exe"; ls -la "$LLAMA_DIR/build/bin" || true; fail=1; }
|
||||
if [ "$fail" != "0" ]; then grep -iE "cmake|visual studio|prebuilt|source build" logs/install.log | tail -60; exit 1; fi
|
||||
echo "Prebuilt installed with no build tools:"
|
||||
cat "$INFO"
|
||||
|
||||
- name: Add Studio shim to GITHUB_PATH
|
||||
run: |
|
||||
SHIM_DIR=~/.unsloth/studio/bin
|
||||
[ -f "$SHIM_DIR/unsloth.exe" ] || { echo "::error::unsloth.exe shim not found"; ls -la ~/.unsloth/studio/ || true; exit 1; }
|
||||
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
||||
|
||||
- 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, log in, load the GGUF
|
||||
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 || { tail -200 logs/studio.log; exit 1; }
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="CINoVS-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token)
|
||||
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
|
||||
-H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \
|
||||
-d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null
|
||||
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
|
||||
echo "API_KEY=$TOKEN" >> "$GITHUB_ENV"
|
||||
LOAD_OK=0
|
||||
for attempt in 1 2 3; do
|
||||
HTTP=$(curl -s -o /tmp/load.json -w '%{http_code}' \
|
||||
-X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||
--max-time 600 \
|
||||
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}")
|
||||
if [ "$HTTP" = "200" ]; then LOAD_OK=1; break; fi
|
||||
echo "::warning::/api/inference/load attempt $attempt returned $HTTP"; cat /tmp/load.json || true; sleep 10
|
||||
done
|
||||
[ "$LOAD_OK" = "1" ] || { echo "::error::/api/inference/load failed 3 attempts"; exit 22; }
|
||||
jq '{status, display_name, is_gguf}' /tmp/load.json
|
||||
|
||||
- name: Inference works via the prebuilt llama.cpp (no VS)
|
||||
run: |
|
||||
RESP=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/v1/chat/completions" \
|
||||
-H "Authorization: Bearer $API_KEY" -H 'content-type: application/json' \
|
||||
--max-time 240 \
|
||||
-d '{"model":"default","messages":[{"role":"user","content":"What is 1+1? Answer briefly."}],"temperature":0,"max_tokens":32,"stream":false}')
|
||||
echo "$RESP" | jq '.choices[0].message' || { echo "$RESP"; exit 1; }
|
||||
CONTENT=$(echo "$RESP" | jq -r '.choices[0].message.content')
|
||||
[ -n "$CONTENT" ] && [ "$CONTENT" != "null" ] || { echo "::error::empty completion"; exit 1; }
|
||||
echo "Inference OK without Visual Studio: $CONTENT"
|
||||
|
||||
- name: Restore Visual Studio + CMake
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
|
||||
$off = "$d.vsoff"
|
||||
if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" }
|
||||
}
|
||||
if ($env:HIDDEN_CMAKE) {
|
||||
foreach ($src in ($env:HIDDEN_CMAKE -split '\|')) {
|
||||
if ($src -and (Test-Path -LiteralPath "$src.off")) { Rename-Item -LiteralPath "$src.off" -NewName (Split-Path $src -Leaf) }
|
||||
}
|
||||
}
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
shell: cmd
|
||||
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
|
||||
|
||||
- name: Collect llama-server logs
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
mkdir -p logs/llama-server
|
||||
cp -v ~/.unsloth/studio/logs/llama-server/*.log logs/llama-server/ 2>/dev/null || echo "no llama-server logs"
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: windows-no-vs-cpu-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/studio.log
|
||||
logs/llama-server/*.log
|
||||
retention-days: 7
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Job B: the GPU (CUDA) prebuilt path is also VS-free (resolve/availability)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
no-vs-gpu-resolve:
|
||||
name: GPU prebuilt resolves without Visual Studio
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
PYTHONIOENCODING: utf-8
|
||||
PYTHONUTF8: '1'
|
||||
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'
|
||||
|
||||
- name: Hide Visual Studio
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
|
||||
if (Test-Path -LiteralPath $d) { Rename-Item -LiteralPath $d -NewName ((Split-Path $d -Leaf) + '.vsoff'); Write-Host "Hid VS: $d" }
|
||||
}
|
||||
|
||||
- name: Windows CUDA and ROCm prebuilts exist in unslothai/llama.cpp (what GPU users download, no VS)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \
|
||||
"https://api.github.com/repos/unslothai/llama.cpp/releases/latest" > /tmp/rel.json
|
||||
echo "release: $(jq -r .tag_name /tmp/rel.json)"
|
||||
ASSETS=$(jq -r '.assets[].name' /tmp/rel.json)
|
||||
echo "$ASSETS" | grep -iE 'windows-x64-cuda[0-9]' || {
|
||||
echo "::error::no Windows x64 CUDA prebuilt asset found in unslothai/llama.cpp latest release"
|
||||
echo "$ASSETS"; exit 1; }
|
||||
# AMD parity: hosted runners have no AMD GPU, so the resolver step below
|
||||
# can't exercise the ROCm path (it resolves to CPU). Pin the per-gfx
|
||||
# Windows ROCm bundles here so a release that drops them fails loudly --
|
||||
# the AMD no-VS guarantee otherwise rides only on shared resolver code.
|
||||
echo "$ASSETS" | grep -iE 'windows-x64-rocm-gfx' || {
|
||||
echo "::error::no Windows x64 ROCm (per-gfx) prebuilt asset found in unslothai/llama.cpp latest release"
|
||||
echo "$ASSETS"; exit 1; }
|
||||
echo "Windows CUDA and ROCm prebuilts are available -- GPU users get them without compiling."
|
||||
|
||||
- name: The prebuilt resolver runs without Visual Studio
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Resolver-only (no GPU on hosted runners, so the host resolves to the
|
||||
# CPU bundle). The point is that resolution needs no compiler/VS.
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
python studio/install_llama_prebuilt.py --resolve-prebuilt latest --output-format json > /tmp/resolve.json || {
|
||||
echo "::error::resolver exited non-zero"; cat /tmp/resolve.json || true; exit 1; }
|
||||
cat /tmp/resolve.json
|
||||
echo "Prebuilt resolver ran with no Visual Studio present."
|
||||
|
||||
- name: Restore Visual Studio
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($d in @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio")) {
|
||||
$off = "$d.vsoff"
|
||||
if (Test-Path -LiteralPath $off) { Rename-Item -LiteralPath $off -NewName (Split-Path $d -Leaf); Write-Host "Restored $d" }
|
||||
}
|
||||
|
||||
# ── folded from studio-setup-ps1-vs2026.yml: setup.ps1 unit tests + real-VS detection + vcredist ──
|
||||
pester:
|
||||
name: setup.ps1 unit tests (VS 2026 / CMake guard)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Pester v5
|
||||
shell: pwsh
|
||||
run: |
|
||||
Set-PSRepository PSGallery -InstallationPolicy Trusted
|
||||
Install-Module Pester -MinimumVersion 5.5.0 -Force -SkipPublisherCheck -Scope CurrentUser
|
||||
Import-Module Pester -MinimumVersion 5.5.0
|
||||
Get-Module Pester | Select-Object Name, Version | Format-Table
|
||||
|
||||
- name: Run Pester suite
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$testDir = Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1'
|
||||
if (-not (Test-Path $testDir)) {
|
||||
Write-Error "Test directory not found: $testDir"
|
||||
exit 1
|
||||
}
|
||||
$cfg = New-PesterConfiguration
|
||||
$cfg.Run.Path = $testDir
|
||||
$cfg.Run.Exit = $true # non-zero exit => job fails
|
||||
$cfg.Run.Throw = $true # also throw on test failure / 0 tests
|
||||
$cfg.TestResult.Enabled = $true
|
||||
$cfg.TestResult.OutputFormat = 'NUnitXml'
|
||||
$cfg.TestResult.OutputPath = Join-Path $env:GITHUB_WORKSPACE 'pester-results.xml'
|
||||
$cfg.Output.Verbosity = 'Detailed'
|
||||
Invoke-Pester -Configuration $cfg
|
||||
|
||||
- name: Upload Pester results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: pester-results-setup-ps1
|
||||
path: pester-results.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
vs-integration:
|
||||
# Real detection against the VS installed on the runner image (no mocks).
|
||||
name: real-VS detection (${{ matrix.label }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: windows-2022, label: 'VS 2022', expectGen: 'Visual Studio 17 2022', expectToolset: 'v170' }
|
||||
- { os: windows-2025-vs2026, label: 'VS 2026', expectGen: 'Visual Studio 18 2026', expectToolset: 'v180' }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect the real Visual Studio with setup.ps1 functions
|
||||
shell: pwsh
|
||||
env:
|
||||
EXPECT_GEN: ${{ matrix.expectGen }}
|
||||
EXPECT_TOOLSET: ${{ matrix.expectToolset }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1')
|
||||
$setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1'
|
||||
foreach ($fn in @('Resolve-VsGeneratorFromLabel', 'Get-VcBuildCustomizationsDir', 'Find-VsBuildTools')) {
|
||||
. ([scriptblock]::Create((Get-FunctionSource -Path $setup -Name $fn)))
|
||||
}
|
||||
|
||||
# Ground truth from the real vswhere (independent of our code), for visibility.
|
||||
$vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
if (Test-Path $vsw) {
|
||||
$year = (& $vsw -latest -property catalog_productLineVersion 2>$null | Select-Object -First 1)
|
||||
$path = (& $vsw -latest -property installationPath 2>$null | Select-Object -First 1)
|
||||
Write-Host "Real vswhere: productLineVersion='$year' installPath='$path'"
|
||||
} else {
|
||||
Write-Host "vswhere not present at $vsw (relying on filesystem fallback)"
|
||||
}
|
||||
|
||||
# Our detection must find the real VS and report the expected generator.
|
||||
$r = Find-VsBuildTools
|
||||
if (-not $r) { throw "Find-VsBuildTools returned null on a host with real $env:EXPECT_GEN" }
|
||||
Write-Host "Find-VsBuildTools -> Generator='$($r.Generator)' Source='$($r.Source)' InstallPath='$($r.InstallPath)'"
|
||||
if ($r.Generator -ne $env:EXPECT_GEN) {
|
||||
throw "Detection mismatch: got '$($r.Generator)', expected '$env:EXPECT_GEN'"
|
||||
}
|
||||
if (-not (Test-Path $r.InstallPath)) { throw "Detected InstallPath does not exist: $($r.InstallPath)" }
|
||||
|
||||
# Toolset path derivation must match the expected v-number...
|
||||
$bc = Get-VcBuildCustomizationsDir -VsInstallPath $r.InstallPath -Generator $r.Generator
|
||||
$derived = Split-Path (Split-Path $bc -Parent) -Leaf # e.g. v170 / v180
|
||||
Write-Host "Get-VcBuildCustomizationsDir -> '$bc' (toolset='$derived')"
|
||||
if ($derived -ne $env:EXPECT_TOOLSET) {
|
||||
throw "Toolset mismatch: derived '$derived', expected '$env:EXPECT_TOOLSET'"
|
||||
}
|
||||
|
||||
# ...and that v-number is a real folder on the VS install (where CUDA's
|
||||
# BuildCustomizations would land).
|
||||
$vcRoot = Join-Path $r.InstallPath 'MSBuild\Microsoft\VC'
|
||||
if (Test-Path $vcRoot) {
|
||||
$realToolsets = @((Get-ChildItem -Path $vcRoot -Directory -ErrorAction SilentlyContinue).Name)
|
||||
Write-Host "Real VC toolset dirs: $($realToolsets -join ', ')"
|
||||
if ($realToolsets -notcontains $derived) {
|
||||
throw "Derived toolset '$derived' is not present on the real $env:EXPECT_GEN install (have: $($realToolsets -join ', '))"
|
||||
}
|
||||
Write-Host "OK: toolset '$derived' exists on the real VS install."
|
||||
} else {
|
||||
Write-Warning "VC MSBuild root absent ($vcRoot) - C++ workload not installed; skipping on-disk toolset check."
|
||||
}
|
||||
|
||||
Write-Host "PASS: real $env:EXPECT_GEN detected correctly with toolset '$derived'."
|
||||
|
||||
vcredist-clean-box:
|
||||
# Validate Test-VCRedistInstalled + Ensure-VCRedist on a throwaway runner:
|
||||
# present on the stock image, fires on a clean box (signals removed restorably),
|
||||
# then a literal uninstall/reinstall round trip. Always restored before the end.
|
||||
name: VC++ runtime detect + install round-trip (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, windows-2025-vs2026]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Detect present, fire on a clean box, and round-trip the install
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $env:GITHUB_WORKSPACE 'tests/studio_setup_ps1/Get-FunctionSource.ps1')
|
||||
$setup = Join-Path $env:GITHUB_WORKSPACE 'studio/setup.ps1'
|
||||
# Dot-source the guard + the logging closure it reaches
|
||||
# (step/substep -> Write-StudioStdoutMirror / Get-StudioAnsi).
|
||||
$script:StudioVtOk = $false
|
||||
$script:UnslothVerbose = $false
|
||||
foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep',
|
||||
'Invoke-SetupCommand', 'Refresh-Environment',
|
||||
'Test-VCRedistInstalled', 'Ensure-VCRedist')) {
|
||||
$src = Get-FunctionSource -Path $setup -Name $fn
|
||||
if (-not $src) { throw "Function '$fn' not found in setup.ps1" }
|
||||
. ([scriptblock]::Create($src))
|
||||
}
|
||||
|
||||
$regKeys = @(
|
||||
'HKLM\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64',
|
||||
'HKLM\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64'
|
||||
)
|
||||
function Show-GroundTruth {
|
||||
$dll = Join-Path $env:SystemRoot 'System32\vcruntime140_1.dll'
|
||||
Write-Host (" System32\vcruntime140_1.dll present: {0}" -f (Test-Path $dll))
|
||||
foreach ($k in $regKeys) {
|
||||
$r = Get-ItemProperty -Path "HKLM:\$($k.Substring(5))" -ErrorAction SilentlyContinue
|
||||
if ($r) { Write-Host (" {0}: Installed={1} {2}.{3}" -f $k, $r.Installed, $r.Major, $r.Minor) }
|
||||
else { Write-Host (" {0}: (absent)" -f $k) }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host '== A. Detection on the stock runner (expect present) =='
|
||||
Show-GroundTruth
|
||||
if (-not (Test-VCRedistInstalled)) { throw 'Test-VCRedistInstalled reported ABSENT on a stock runner that ships the VC++ runtime (detection regression).' }
|
||||
Write-Host ' Test-VCRedistInstalled -> present OK'
|
||||
|
||||
Write-Host '== B. Genuinely clean box (restorable): detection must FIRE =='
|
||||
$scratch = Join-Path $env:RUNNER_TEMP 'cleanwin'
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $scratch 'System32') | Out-Null
|
||||
$backup = Join-Path $env:RUNNER_TEMP 'vcreg_backup'
|
||||
New-Item -ItemType Directory -Force -Path $backup | Out-Null
|
||||
$origSysRoot = $env:SystemRoot
|
||||
try {
|
||||
for ($i = 0; $i -lt $regKeys.Count; $i++) {
|
||||
reg query $regKeys[$i] *> $null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
reg export $regKeys[$i] (Join-Path $backup "$i.reg") /y *> $null
|
||||
reg delete $regKeys[$i] /f *> $null
|
||||
}
|
||||
}
|
||||
$env:SystemRoot = $scratch
|
||||
if (Test-VCRedistInstalled) { throw 'Detection still PRESENT after both signals were removed (it would never trigger an install on a clean box).' }
|
||||
Write-Host ' Test-VCRedistInstalled -> absent OK (detection fires on a clean box)'
|
||||
} finally {
|
||||
$env:SystemRoot = $origSysRoot
|
||||
for ($i = 0; $i -lt $regKeys.Count; $i++) {
|
||||
$f = Join-Path $backup "$i.reg"
|
||||
if (Test-Path $f) { reg import $f *> $null }
|
||||
}
|
||||
}
|
||||
Show-GroundTruth
|
||||
if (-not (Test-VCRedistInstalled)) { throw 'Detection did not recover after restoring the registry (test restore bug).' }
|
||||
|
||||
Write-Host '== C. Literal uninstall on this throwaway VM (official installer), observe detection =='
|
||||
$exe = Join-Path $env:RUNNER_TEMP 'vc_redist.x64.exe'
|
||||
Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vc_redist.x64.exe' -OutFile $exe
|
||||
Start-Process -FilePath $exe -ArgumentList '/uninstall', '/quiet', '/norestart' -Wait
|
||||
Show-GroundTruth
|
||||
Write-Host (" Test-VCRedistInstalled after uninstall -> {0}" -f (Test-VCRedistInstalled))
|
||||
if (Test-VCRedistInstalled) {
|
||||
Write-Host ' Note: the Visual Studio on this image ref-counts the runtime, so the package'
|
||||
Write-Host ' uninstall is a no-op here; section B already proved detection on a clean box.'
|
||||
}
|
||||
|
||||
Write-Host '== D. Restore via Ensure-VCRedist (winget product path), installer fallback if needed =='
|
||||
Ensure-VCRedist
|
||||
if (-not (Test-VCRedistInstalled)) {
|
||||
Write-Host ' winget path did not restore it; using the official installer to close the round trip.'
|
||||
Start-Process -FilePath $exe -ArgumentList '/install', '/quiet', '/norestart' -Wait
|
||||
}
|
||||
Show-GroundTruth
|
||||
if (-not (Test-VCRedistInstalled)) { throw 'VC++ runtime could not be restored after the uninstall round-trip.' }
|
||||
Write-Host ' Test-VCRedistInstalled -> present OK'
|
||||
Write-Host 'PASS: detection is correct on a real install, fires on a clean box, and the install round-trip restores the runtime.'
|
||||
|
|
|
|||
80
.github/workflows/studio-windows-ui-smoke.yml
vendored
80
.github/workflows/studio-windows-ui-smoke.yml
vendored
|
|
@ -91,7 +91,8 @@ jobs:
|
|||
id: prime-hf
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true' || steps.cache-hf.outcome != 'success'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_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: |
|
||||
python -m pip install --upgrade huggingface_hub
|
||||
mkdir -p hf-cache
|
||||
|
|
@ -136,6 +137,17 @@ jobs:
|
|||
}
|
||||
}
|
||||
|
||||
- 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
|
||||
|
|
@ -144,7 +156,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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,
|
||||
|
|
@ -192,6 +205,69 @@ jobs:
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -79,15 +79,15 @@ jobs:
|
|||
# Two surgical fixes against measured Windows-only install
|
||||
# waste (vs Mac/Linux on the same SHA):
|
||||
#
|
||||
# (1) npm. setup.ps1 line 1109-1145 requires Node 22.12+ (or
|
||||
# 20.19+ / 23+) AND npm >=11 because Vite 8 needs both.
|
||||
# (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 npm
|
||||
# check fails and setup.ps1 falls through to the
|
||||
# "winget install Node.js LTS" branch -- a ~35 s reinstall
|
||||
# of Node we don't need. `npm install -g npm@^11` updates
|
||||
# the bundled npm in-place in ~5 s, which makes setup.ps1
|
||||
# short-circuit on the existing Node.
|
||||
# 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 =
|
||||
|
|
@ -133,7 +133,8 @@ jobs:
|
|||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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;
|
||||
|
|
@ -180,7 +181,8 @@ jobs:
|
|||
- name: First update should be a no-op (prebuilt already validated)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
@ -199,7 +201,8 @@ jobs:
|
|||
- name: Second update must also be a no-op
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HF_TOKEN: ${{ secrets.HF_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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue