* Fix Windows no-torch setup * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix no-torch env normalization on Windows * Accept on for Windows no-torch mode * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep no-torch mode across studio update on Windows Guarding the direct torch/Triton install made `install.ps1 --no-torch` actually produce a torch-free venv, which then broke the next `unsloth studio update`. That path exports no UNSLOTH_NO_TORCH, so $NoTorchMode was false, the stale-venv check read the missing torch as a broken venv, and setup tried to delete the venv it was running out of: [ERROR] Could not remove stale venv: Access to the path 'python.exe' is denied. That teardown can never succeed there, because setup.ps1 runs via unsloth.exe out of that same venv. The same gap also let the shared dependency pass reinstall torch from PyPI, unpinned, into a GGUF-only environment. install_python_stack.py now records the mode in the install manifest and setup.ps1 reads it back when no env var is exported, then re-exports a canonical value for the dependency pass (setup.ps1 drops the manifest before invoking it, so the child cannot repeat the lookup). The key is additive and MANIFEST_SCHEMA is unchanged, so existing manifests stay valid and a missing key keeps today's behaviour. Also: - read_manifest() caught only OSError, but UnicodeDecodeError is a ValueError. That is now on the installer's import path, so a manifest re-saved as ANSI or truncated mid-write would abort every install. - The env predicate now trims surrounding whitespace, matching the Python side. - The Windows update smoke workflow asserts the update leaves the venv GGUF-only, which is what would have caught this. Known follow-up, pre-existing: an install killed between the manifest drop and the dependency pass leaves no recorded mode, so a later update still walks the stale-venv path. Closing that needs a marker the installer never drops. * Persist no-torch mode in a marker the dependency pass cannot drop The install manifest alone was not enough. Both setup.ps1 and install_python_stack.py remove it before every dependency pass, and it is only rewritten on success, so a no-torch install interrupted in between left nothing recording the mode. The next update then resolved no-torch as false, read the expected missing torch as a stale venv, and tried to delete the environment whose python.exe was running it, which leaves the install unrepairable from the CLI. Add .unsloth-no-torch next to the existing .unsloth-studio-owned marker, written before the pass and cleared when torch is wanted. setup.ps1 writes it as soon as the mode resolves, so the window between the manifest drop and its own torch install is covered too. Read order stays manifest key first, then marker, so migrating out of no-torch is never blocked by a marker an earlier run left behind. Neither present still reads as "install torch", so nothing changes for installs made before either existed. Also adds the AGPL-3.0 header the new test file was missing. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothai@gmail.com>
320 lines
14 KiB
YAML
320 lines
14 KiB
YAML
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
# Windows counterpart to studio-update-smoke.yml /
|
|
# studio-mac-update-smoke.yml. Verifies that on the FREE
|
|
# windows-latest runner:
|
|
#
|
|
# 1. install.ps1 --local --no-torch installs Unsloth AND auto-fetches
|
|
# the prebuilt llama.cpp Windows binary (app-<tag>-windows-x64-cpu
|
|
# from unslothai/llama.cpp). Hitting the source-build fallback is
|
|
# treated as an Unsloth bug -- Unsloth must always pick the
|
|
# prebuilt on Windows.
|
|
# 2. unsloth studio update --local is idempotent. Two consecutive
|
|
# runs both report "prebuilt up to date and validated", no
|
|
# source-build fallback. The CLI's _find_setup_script picks
|
|
# setup.ps1 on Windows automatically.
|
|
# 3. The installed Unsloth still boots and /api/health returns
|
|
# healthy after the update path.
|
|
|
|
name: Windows Unsloth Update CI
|
|
|
|
on:
|
|
pull_request:
|
|
paths:
|
|
- 'install.ps1'
|
|
- 'scripts/uninstall.ps1'
|
|
- 'studio/setup.ps1'
|
|
- 'studio/setup.bat'
|
|
- 'studio/install_python_stack.py'
|
|
- 'studio/install_llama_prebuilt.py'
|
|
- 'studio/backend/requirements/**'
|
|
- 'unsloth_cli/commands/studio.py'
|
|
- 'pyproject.toml'
|
|
- '.github/workflows/studio-windows-update-smoke.yml'
|
|
push:
|
|
branches: [main, pip]
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
update-idempotency:
|
|
name: Unsloth Updating Tests
|
|
runs-on: windows-latest
|
|
timeout-minutes: 30
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
env:
|
|
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
|
|
# download / Unsloth CLI print "✓" checkmarks and crash
|
|
# otherwise).
|
|
PYTHONIOENCODING: utf-8
|
|
PYTHONUTF8: '1'
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
with:
|
|
node-version: '22'
|
|
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
with:
|
|
python-version: '3.12'
|
|
# Don't cache pip: install.ps1 + setup.ps1 go through uv
|
|
# and never populate ~/.cache/pip; setup-python's post-step
|
|
# then fatal-errors with "Cache folder path is retrieved
|
|
# for pip but doesn't exist on disk".
|
|
|
|
- name: Pre-install Windows tweaks (npm 11 + Defender exclusions)
|
|
shell: pwsh
|
|
# Two surgical fixes against measured Windows-only install
|
|
# waste (vs Mac/Linux on the same SHA):
|
|
#
|
|
# (1) npm. setup.ps1's Get-NodeDecision requires Node 22.12+
|
|
# (or 20.19+ / 23+) AND npm >=11 because Vite 8 needs both.
|
|
# actions/setup-node@v4 with `node-version: '22'` lands
|
|
# Node 22.22.2 + the npm 10.9.7 it bundles, so the decision
|
|
# is "bundled" and setup.ps1 downloads an isolated Node (~30
|
|
# MB) we don't need on a runner that already has a fine Node.
|
|
# `npm install -g npm@^11` updates the runner's npm in-place
|
|
# in ~5 s, flipping the decision to "system" so setup.ps1
|
|
# reuses the existing Node with no download.
|
|
#
|
|
# (2) Defender. windows-latest's real-time scan opens / hashes
|
|
# every file Unsloth writes during install (Vite output =
|
|
# thousands of small chunks, uv pip = wheel-extraction =
|
|
# thousands of small files). The latency dominates the
|
|
# 200 s frontend build and the 90 s deps install. Adding
|
|
# ExclusionPath entries for the directories the install
|
|
# writes to drops per-file open latency from ~ms to ~us.
|
|
# Add-MpPreference needs admin; the runneradmin user has
|
|
# it, but wrap in try/catch so a permission flake leaves
|
|
# the install otherwise unaffected.
|
|
run: |
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
Write-Host "npm version before upgrade: $(npm -v)"
|
|
npm install -g 'npm@^11' 2>&1 | Out-Host
|
|
Write-Host "npm version after upgrade: $(npm -v)"
|
|
# NOTE: do NOT pre-create these directories before adding the
|
|
# exclusion -- creating an empty studio/frontend/dist trips
|
|
# setup.ps1 line 1281-1296's mtime-based "is the frontend
|
|
# stale?" check into "up to date, skip rebuild", because the
|
|
# newly-created dist's mtime is younger than every source
|
|
# file. Unsloth then boots with an empty dist and 500s on
|
|
# GET / with FileNotFoundError: dist\index.html. See run
|
|
# 25546676715 / job 74984469728.
|
|
# Add-MpPreference accepts paths that do not yet exist; the
|
|
# exclusion is registered and applies when the path
|
|
# materialises.
|
|
foreach ($p in @(
|
|
"$env:USERPROFILE\.unsloth",
|
|
"$env:USERPROFILE\AppData\Local\uv",
|
|
"$env:GITHUB_WORKSPACE\studio\frontend\node_modules",
|
|
"$env:GITHUB_WORKSPACE\studio\frontend\dist"
|
|
)) {
|
|
try {
|
|
Add-MpPreference -ExclusionPath $p -ErrorAction Stop
|
|
Write-Host "Defender exclusion added: $p"
|
|
} catch {
|
|
Write-Host "Defender exclusion skipped ($($_.Exception.Message)): $p"
|
|
}
|
|
}
|
|
|
|
- name: Install Unsloth (--local, --no-torch)
|
|
shell: pwsh
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
|
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
|
run: |
|
|
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
|
# *>&1 captures Write-Host (Information stream) output;
|
|
# plain 2>&1 does not. setup.ps1 emits "prebuilt installed
|
|
# and validated" via Write-Host, and we grep for that.
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
& ./install.ps1 --local --no-torch *>&1 | Tee-Object -FilePath logs/install.log
|
|
|
|
- name: Assert install.ps1 used the Windows llama.cpp prebuilt
|
|
run: |
|
|
# Filesystem-based check (setup.ps1's stream output isn't
|
|
# captured back through the parent pipeline).
|
|
LLAMA_DIR=~/.unsloth/llama.cpp
|
|
INFO="$LLAMA_DIR/UNSLOTH_PREBUILT_INFO.json"
|
|
BIN="$LLAMA_DIR/build/bin/Release/llama-server.exe"
|
|
if grep -q "falling back to source build" logs/install.log; then
|
|
echo "::error::install.ps1 fell back to source-build llama.cpp on Windows."
|
|
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
|
|
exit 1
|
|
fi
|
|
if [ ! -f "$INFO" ]; then
|
|
echo "::error::no UNSLOTH_PREBUILT_INFO.json at $INFO."
|
|
ls -la "$LLAMA_DIR" || true
|
|
exit 1
|
|
fi
|
|
if [ ! -f "$BIN" ]; then
|
|
echo "::error::no llama-server.exe at $BIN."
|
|
ls -la "$LLAMA_DIR/build/bin" || true
|
|
exit 1
|
|
fi
|
|
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
|
|
cat "$INFO"
|
|
|
|
- name: Add Unsloth shim to GITHUB_PATH
|
|
run: |
|
|
SHIM_DIR=~/.unsloth/studio/bin
|
|
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
|
|
echo "::error::unsloth.exe shim not found at $SHIM_DIR"
|
|
ls -la ~/.unsloth/studio/ || true
|
|
exit 1
|
|
fi
|
|
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
|
|
|
|
- name: First update should be a no-op (prebuilt already validated)
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
|
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
|
run: |
|
|
set -o pipefail
|
|
unsloth studio update --local 2>&1 | tee logs/update.log
|
|
if grep -q "falling back to source build" logs/update.log; then
|
|
echo "::error::studio update fell back to source-build llama.cpp on Windows."
|
|
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
|
exit 1
|
|
fi
|
|
if ! grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update.log; then
|
|
echo "::error::no prebuilt up-to-date marker in update.log."
|
|
grep -E "llama-prebuilt|llama.cpp" logs/update.log | tail -60
|
|
exit 1
|
|
fi
|
|
echo "update path took the prebuilt fast path"
|
|
|
|
- name: Update must keep the --no-torch install GGUF-only
|
|
run: |
|
|
# `unsloth studio update` exports no UNSLOTH_NO_TORCH, so setup.ps1 has
|
|
# to recover the mode from the install manifest. Without that it reads
|
|
# the missing torch as a stale venv and tries to delete the venv it is
|
|
# running out of, and the shared dependency pass pulls torch back in.
|
|
# The skip line only prints when the dependency pass actually runs, so
|
|
# don't demand it if the fast path short-circuited that pass.
|
|
if grep -q "running ordered dependency installation" logs/update.log \
|
|
&& ! grep -q "skipping direct PyTorch and Triton installation (no-torch mode)" logs/update.log; then
|
|
echo "::error::studio update left no-torch mode; it would reinstall PyTorch."
|
|
grep -iE "no-torch|stale venv|PyTorch" logs/update.log | tail -40
|
|
exit 1
|
|
fi
|
|
PY="$HOME/.unsloth/studio/unsloth_studio/Scripts/python.exe"
|
|
if [ ! -f "$PY" ]; then
|
|
echo "::error::studio venv interpreter missing at $PY"
|
|
exit 1
|
|
fi
|
|
if "$PY" -c "import torch" 2>/dev/null; then
|
|
echo "::error::torch was reinstalled into the --no-torch venv."
|
|
exit 1
|
|
fi
|
|
echo "update preserved no-torch mode"
|
|
|
|
- name: Second update must also be a no-op
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
|
|
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
|
|
run: |
|
|
set -o pipefail
|
|
unsloth studio update --local 2>&1 | tee logs/update2.log
|
|
grep -q "falling back to source build" logs/update2.log && {
|
|
echo "::error::second update fell back to source build on Windows"
|
|
tail -60 logs/update2.log; exit 1; } || true
|
|
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
|
|
echo "second update was clean"
|
|
|
|
- name: Boot Unsloth briefly to confirm the install is still usable
|
|
run: |
|
|
mkdir -p logs
|
|
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \
|
|
> logs/studio.log 2>&1 &
|
|
PID=$!
|
|
HEALTHY=""
|
|
# Use jq (a Git Bash builtin) instead of `python -c
|
|
# open('/tmp/health.json')` to read the saved health
|
|
# response. Bash on windows-latest is MSYS Git Bash, which
|
|
# resolves `/tmp/...` against the MSYS root, while the
|
|
# python interpreter is Windows-native and resolves it
|
|
# against the current drive's root. The two paths don't
|
|
# agree, so python never finds the file curl just wrote.
|
|
# jq reads through MSYS, so the path matches. Mirrors what
|
|
# studio-windows-api-smoke.yml and the other Windows smoke
|
|
# workflows already do.
|
|
for i in $(seq 1 60); do
|
|
if curl -fs http://127.0.0.1:18891/api/health > /tmp/health.json; then
|
|
if jq -e '.status == "healthy"' /tmp/health.json >/dev/null; then
|
|
HEALTHY=1
|
|
break
|
|
fi
|
|
fi
|
|
sleep 1
|
|
done
|
|
if [ -z "$HEALTHY" ]; then
|
|
echo "Unsloth failed to come up after \`update\`"
|
|
tail -200 logs/studio.log
|
|
kill "$PID" 2>/dev/null || true
|
|
exit 1
|
|
fi
|
|
kill "$PID" 2>/dev/null || true
|
|
echo "post-update Unsloth /api/health OK"
|
|
|
|
- name: Uninstall and verify clean
|
|
# Round-trip through scripts/uninstall.ps1 against the default
|
|
# install tree at %USERPROFILE%\.unsloth\studio. Catches
|
|
# regressions where install.ps1 starts writing under a new key
|
|
# (registry, Start Menu, %APPDATA%) and scripts/uninstall.ps1 has
|
|
# not been updated to match. Skips gracefully if
|
|
# scripts/uninstall.ps1 has not landed yet (lets this workflow
|
|
# merge before #5513).
|
|
shell: pwsh
|
|
run: |
|
|
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
|
if (-not (Test-Path "$PWD\scripts\uninstall.ps1")) {
|
|
Write-Host "scripts/uninstall.ps1 not present in this tree; skipping round-trip"
|
|
"" | Set-Content logs/uninstall.log
|
|
exit 0
|
|
}
|
|
pwsh -NoProfile -File "$PWD\scripts\uninstall.ps1" *>&1 | Tee-Object -FilePath logs/uninstall.log
|
|
$leak = 0
|
|
foreach ($p in @(
|
|
"$env:USERPROFILE\.unsloth\studio",
|
|
"$env:USERPROFILE\.unsloth\studio\unsloth_studio",
|
|
"$env:USERPROFILE\.unsloth\studio\bin\unsloth.exe"
|
|
)) {
|
|
if (Test-Path -LiteralPath $p) {
|
|
Write-Host "::error::leak: $p"
|
|
$leak++
|
|
}
|
|
}
|
|
if ($leak -gt 0) { exit 1 }
|
|
# Idempotency.
|
|
pwsh -NoProfile -File "$PWD\scripts\uninstall.ps1" *>&1 | Select-Object -Last 5
|
|
pwsh -NoProfile -File "$PWD\scripts\uninstall.ps1" *>&1 | Select-Object -Last 5
|
|
Write-Host "PASS: windows install -> update -> uninstall round-trip clean"
|
|
|
|
- name: Upload update logs
|
|
if: always()
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: windows-studio-update-log
|
|
path: |
|
|
logs/install.log
|
|
logs/update.log
|
|
logs/update2.log
|
|
logs/studio.log
|
|
logs/uninstall.log
|
|
retention-days: 7
|