diff --git a/.github/scripts/assert-nobuild.ps1 b/.github/scripts/assert-nobuild.ps1 new file mode 100644 index 0000000000..7ac86cb5d2 --- /dev/null +++ b/.github/scripts/assert-nobuild.ps1 @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# The `nobuild` contract from clean-machine-assert.sh, for Windows. A port and not +# `shell: bash`: the scrub drops every `*\Git\*` PATH entry and the bash version needs +# sed/grep/tr/sort out of Git's usr/bin, and it also runs inside the servercore +# container, which has no bash. Both Windows lanes call this one file so the sdist +# allowlist cannot drift. +# +# Usage: assert-nobuild.ps1 -LogPath logs/install.log (exit 1 = a source build) +[CmdletBinding()] +param([Parameter(Mandatory = $true)][string] $LogPath) + +if (-not (Test-Path -LiteralPath $LogPath)) { + Write-Host "::error::nobuild requested but $LogPath is missing" + exit 1 +} + +# "Built an sdist" is NOT "needed a compiler": every name here was verified against its +# own sdist -- setuptools.build_meta backend, no ext_modules, no .c/.cpp/.pyx/.rs file +# -- so its PEP 517 build is a pure-Python copy step. Identical to +# clean-machine-assert.sh, which carries the per-name rationale. +$allow = @('openai-whisper', 'argbind', 'randomname', 'antlr4-python3-runtime', 'triton-kernels') +if ($env:UNSLOTH_ALLOW_SDIST) { + $allow += ($env:UNSLOTH_ALLOW_SDIST -split '\s+' | Where-Object { $_ }) +} +# Lowercased and underscore-folded on both sides: a distribution name and the name uv +# prints can disagree on the separator (triton_kernels vs triton-kernels). +$allow = @($allow | ForEach-Object { $_.ToLowerInvariant() -replace '_', '-' }) + +# [char]27, not "`e": that escape is PowerShell 6+, and under Windows PowerShell 5.1 +# it degrades to a literal "e" and the strip eats real text instead of ANSI codes. +$esc = [char]27 +$text = (Get-Content -LiteralPath $LogPath -Raw) -replace "$esc\[[0-9;]*[A-Za-z]", '' +$built = @() +foreach ($line in ($text -split "`r?`n")) { + # A local-path build is one the caller pointed at (the CI source overlay), never + # one resolution chose; index dependencies always print `==`. + if ($line -imatch 'building [a-z0-9._-]+ @ file://') { continue } + # pip prints `Building wheel for `, uv prints `Building ==` + # (astral-sh/uv#11165); the `==` or ` @ ` requirement keeps this off the + # installer's own lowercase "building frontend..." text. + foreach ($m in [regex]::Matches($line, '(?i)building wheel for ([a-z0-9._-]+)|building ([a-z0-9._-]+)(==| @ )')) { + $name = if ($m.Groups[1].Success) { $m.Groups[1].Value } else { $m.Groups[2].Value } + $built += ($name.ToLowerInvariant() -replace '_', '-') + } +} +$built = @($built | Sort-Object -Unique) +$bad = @($built | Where-Object { $allow -notcontains $_ }) + +$rc = 0 +if ($bad.Count -gt 0) { + Write-Host "::error::built from source: $($bad -join ' ') -- these must resolve to wheels on a clean machine" + $rc = 1 +} else { + Write-Host "[assert] OK no non-allowlisted source build (built: $(if ($built) { $built -join ' ' } else { 'none' }))" +} +# Independent of package names: a compiler error means a toolchain was needed. +$compilerErr = Select-String -Path $LogPath -Pattern "error: command '(cc|gcc|clang|cl)' failed", 'clang: error', 'cargo: not found', 'Microsoft Visual C\+\+ 14.0 or greater is required' +if ($compilerErr) { + Write-Host '::error::compiler invocation appears in the install log' + $compilerErr | Select-Object -First 10 | ForEach-Object { Write-Host " $($_.Line)" } + $rc = 1 +} +exit $rc diff --git a/.github/scripts/clean-machine-assert.sh b/.github/scripts/clean-machine-assert.sh new file mode 100755 index 0000000000..dc528e480d --- /dev/null +++ b/.github/scripts/clean-machine-assert.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +# +# Assert the clean-machine contract after an install attempt. +# +# absent The toolchain really was absent for the whole run. Catches a leg that +# "passed" because masking silently failed, or because the installer +# quietly installed Xcode CLT behind our back. +# notools The trace recorded no compiler/git/brew invocation (trace mode). +# nobuild Wheels-only: no "Building wheel" from pip, no "Building ==" +# from uv. Needs UNSLOTH_VERBOSE=1, or run_install_cmd +# (install.sh:193-243) discards uv's output on success. +# macho Every Mach-O under $MACHO_ROOT is the host architecture, and every +# Mach-O MAIN EXECUTABLE is signed. Closes the Rosetta 2 gap, the one +# divergence masking cannot reproduce. +# +# Usage: bash .github/scripts/clean-machine-assert.sh absent notools nobuild macho +set -uo pipefail + +LOG="${INSTALL_LOG:-logs/install.log}" +TRACE="${UNSLOTH_TOOL_TRACE:-}" +rc=0 + +fail() { echo "::error::$*"; rc=1; } +ok() { echo "[assert] OK $*"; } + +for check in "$@"; do + case "$check" in + + absent) + # NOT `command -v`: on a virgin Mac /usr/bin/{git,cc} EXIST as CLT stubs, so it + # succeeds and only RUNNING them fails. The invariant is: must not WORK. + if xcode-select -p >/dev/null 2>&1; then + fail "xcode-select -p still resolves to $(xcode-select -p 2>/dev/null); not a clean Mac" + else + ok "xcode-select -p fails (the gate a virgin Mac hits)" + fi + for tool in git cc clang cmake; do + command -v "$tool" >/dev/null 2>&1 || { ok "$tool not on PATH"; continue; } + if "$tool" --version >/dev/null 2>&1; then + # On Intel runners /usr/bin/git is not CLT-provided, so no masking can take + # it away. cc and clang do become stubs and the macOS consumer path needs + # no git, so report rather than fail. + case " ${UNSLOTH_CLEAN_ALLOW_WORKING:-} " in + *" $tool "*) + echo "[assert] NOTE $tool still works ($(command -v "$tool")); allowed on this runner" + continue + ;; + esac + fail "toolchain still usable: '$tool --version' succeeded ($(command -v "$tool")); masking failed" + else + ok "$tool present but non-functional (CLT stub), as on a clean Mac" + fi + done + # brew is a plain binary with no stub, so absence from PATH is the right test. + if command -v brew >/dev/null 2>&1; then + fail "Homebrew still on PATH at $(command -v brew); masking failed" + else + ok "brew absent" + fi + ;; + + notools) + if [ -z "$TRACE" ] || [ ! -f "$TRACE" ]; then + fail "notools requested but no trace file (\$UNSLOTH_TOOL_TRACE=$TRACE)" + else + # git is legitimate under --local (unsloth-zoo comes from a git URL), so that + # leg allow-lists it via UNSLOTH_ALLOW_TOOLS. + allow="${UNSLOTH_ALLOW_TOOLS:-}" + hits="" + while IFS=$'\t' read -r tool rest; do + [ -n "$tool" ] || continue + case " $allow " in *" $tool "*) continue ;; esac + # `xcode-select -p` only ASKS whether a toolchain is selected, and the fix + # is that the installer carries on without one, so it is not USE. + # `--install`, which pops the CLT installer, stays a hit. + if [ "$tool" = "xcode-select" ]; then + case "$rest" in + -p|--print-path|-v|--version|"") continue ;; + esac + fi + hits="$hits $tool" + done < "$TRACE" + if [ -n "$hits" ]; then + fail "installer invoked toolchain:$(echo "$hits" | tr ' ' '\n' | sort -u | tr '\n' ' ')" + echo "---- tool trace ----"; sort -u "$TRACE" | head -50 + else + ok "no compiler/git/brew invocation recorded" + fi + fi + ;; + + nobuild) + # "Built an sdist" is NOT "needed a compiler". Every name below was verified + # against its own sdist: setuptools.build_meta backend, no ext_modules, no + # .c/.cpp/.pyx/.rs file, so its PEP 517 build is a pure-Python copy step. + # openai-whisper, argbind, randomname -- no version ever ships a wheel + # antlr4-python3-runtime==4.9.3 -- pinned below the 4.13.2 wheel + # triton-kernels -- requirements/triton-kernels.txt pins a git URL under the + # triton repo's python/triton_kernels subdir: 75 Python files, a four-line + # pyproject.toml, no setup.py, kernels compiled at runtime. A direct URL the + # installer names itself, not something resolution chose, and only the Linux + # legs reach it (install_python_stack.py skips it on Windows and macOS). + # UNSLOTH_ALLOW_SDIST extends the allowlist. + # + # Lowercased and underscore-folded on both sides: a distribution name and the + # name uv prints can disagree on the separator (triton_kernels vs triton-kernels). + _allow="$(printf '%s' "openai-whisper argbind randomname antlr4-python3-runtime triton-kernels ${UNSLOTH_ALLOW_SDIST:-}" | tr 'A-Z_' 'a-z-')" + if [ ! -f "$LOG" ]; then + fail "nobuild requested but $LOG is missing" + else + # uv prints `Building ==`, pip prints `Building wheel for + # ` (astral-sh/uv#11165), so match both; the `==` or ` @ ` requirement + # keeps this off the installer's own lowercase "building frontend..." text, and + # ANSI is stripped first so a coloured run (FORCE_COLOR) parses. + # `Building @ file://...` is dropped: a local-path build is one the + # caller pointed at (--local, or the editable overlay), never one resolution + # chose. Index dependencies always print `==`, so a genuine + # PyPI sdist is still caught, including one named unsloth. + _esc=$(printf '\033') + _built="$(sed -E "s/${_esc}\[[0-9;]*[A-Za-z]//g" "$LOG" 2>/dev/null \ + | grep -viE "building [a-z0-9._-]+ @ file://" \ + | grep -oiE "building wheel for [a-z0-9._-]+|building [a-z0-9._-]+(==| @ )" \ + | tr 'A-Z' 'a-z' \ + | sed -E -e 's/^building wheel for //' -e 's/^building //' -e 's/(==| @ )$//' \ + | tr '_' '-' \ + | sort -u || true)" + _bad="" + for pkg in $_built; do + case " $_allow " in *" $pkg "*) continue ;; esac + _bad="$_bad $pkg" + done + if [ -n "$_bad" ]; then + fail "built from source:$_bad -- these must resolve to wheels on a clean machine" + else + [ -n "$_built" ] && say_built="$(echo "$_built" | tr '\n' ' ')" || say_built="none" + ok "no non-allowlisted source build (built: $say_built)" + fi + # Independent of package names: a compiler error means a toolchain was needed. + if grep -qiE "error: command '(cc|gcc|clang|cl)' failed|no such file or directory: 'cc'|clang: error|cargo: not found|error: linker \`cc\` not found" "$LOG"; then + fail "compiler invocation appears in the install log" + grep -iE "error: command '(cc|gcc|clang|cl)' failed|clang: error" "$LOG" | head -10 + fi + fi + ;; + + macho) + # The one thing masking cannot reproduce: Rosetta 2 is preinstalled on hosted + # runners and absent from a factory-fresh Mac, so an x86_64-only payload runs + # green here and dies with "bad CPU type in executable" for the user. `lipo` is an + # xcrun shim and gone after masking, so read `file -b`, keyed off `uname -m` + # (macos-15-intel is x86_64). + # + # SCOPE: all of $MACHO_ROOT, .venv_t5_510/_530/_550 sidecars included. Those are + # payload, not scratch: setup.sh:579-581 creates them during a normal install and + # transformers_version.py:338-348 puts them on sys.path. Any exclusion must be a + # named path rule, never a narrowed find. + root="${MACHO_ROOT:-${UNSLOTH_STUDIO_HOME:-$HOME/.unsloth}}" + want="$(uname -m)" + [ "$want" = "aarch64" ] && want=arm64 + if [ ! -d "$root" ]; then + fail "macho requested but $root does not exist" + else + # SCOPE, part 2: the two payloads the install RUNS ON live outside $root. + # `uv venv` links /bin/python at its base interpreter rather than copying + # it, and the find below has no -L, so the interpreter that ran every install + # step is invisible to it; the uv that fetched it lands in $HOME/.local/bin. + # Both are exactly what Rosetta 2 hides: an x86_64 uv or managed CPython runs + # green here and dies on the factory-fresh Mac this job stands in for. + _macho_targets() { + find "$root" -type f \( -perm -u+x -o -name '*.dylib' -o -name '*.so' -o -name '*.node' \) 2>/dev/null + # -L follows the interpreter symlink; -maxdepth keeps this a bin/ lookup and + # not a second walk of site-packages through the venv's lib64 link. Depth 4 + # covers /unsloth_studio, the .venv_t5_* sidecars and the tauri layout's + # /studio/unsloth_studio. + find -L "$root" -maxdepth 4 -type f -path '*/bin/python' 2>/dev/null + for _uv in "$HOME/.local/bin/uv" "$(command -v uv 2>/dev/null || true)"; do + [ -n "$_uv" ] && [ -f "$_uv" ] && printf '%s\n' "$_uv" + done + } + n=0 nexe=0 nout=0 bad_arch="" unsigned="" broken="" + while IFS= read -r f; do + desc="$(file -b "$f" 2>/dev/null || true)" + case "$desc" in *Mach-O*) ;; *) continue ;; esac + n=$((n + 1)) + case "$f" in "$root"/*) ;; *) nout=$((nout + 1)) ;; esac + # Substring, not equality: a universal binary lists every slice it carries, + # and one that includes the host arch is fine. + case "$desc" in + *"$want"*) ;; + *) bad_arch="$bad_arch $f [$desc]" ;; + esac + + # Signature: MAIN EXECUTABLES ONLY. Asserting it on every Mach-O failed the + # mask/pipe leg on 29 ordinary PyPI extension modules plus libportaudio.dylib: + # MH_BUNDLE/MH_DYLIB images dlopen'd without library validation, shipped + # unsigned, and that run had already imported them with the installer exiting + # 0. macOS enforces on main executables and gatekept .app bundles. + # + # Key off the filetype `file` reports, not the path: a .so may be a bundle or a + # dylib, and an executable may have no extension. The library veto is second so + # a mixed-type fat file counts as a library. Substring tests are + # order-independent (Apple prints `... executable arm64`, GNU `... arm64 + # executable`). + _is_exe=0 + case "$desc" in *executable*) _is_exe=1 ;; esac + case "$desc" in *"shared library"*|*bundle*) _is_exe=0 ;; esac + # Named rule so a failure says which path matched; the filetype test + # already covers the MH_EXECUTE at .app/Contents/MacOS/. + case "$f" in *.app/Contents/MacOS/*) _is_exe=1 ;; esac + [ "$_is_exe" = 1 ] && nexe=$((nexe + 1)) + + # arm64 only: the kernel refuses to exec an unsigned arm64 main binary + # ("Killed: 9"), while x86_64 execs it happily, so an unsigned x86_64 + # payload is not the same defect. + if [ "$want" = "arm64" ] && [ "$_is_exe" = 1 ]; then + # Ad-hoc counts as signed: arm64 linkers seal ad-hoc by default, so the test + # is "has a seal that verifies", not "has an identity". `spctl` and + # `--strict` would demand an authority and reject ad-hoc. + if ! codesign -v "$f" >/dev/null 2>&1; then + # Nothing to verify and a seal that does not match mean different things. + # Captured, not piped into grep: `codesign -dvv` exits non-zero on an + # unsigned file, and under `pipefail` that is the pipeline's status even + # on a match. + _sig="$(codesign -dvv "$f" 2>&1 || true)" + case "$_sig" in + *"not signed at all"*) unsigned="$unsigned $f" ;; + *) broken="$broken $f" ;; + esac + fi + fi + done < <(_macho_targets | sort -u) + if [ "$n" = "0" ]; then + # An empty scan reads exactly like a clean one, so a wrong root would pass + # and prove nothing. + fail "no Mach-O found under $root; the arch/signature assertion proved nothing" + elif [ "$nout" = "0" ]; then + # Same rule for the roots added above: install.sh always bootstraps uv into + # $HOME/.local/bin, so zero hits outside $root means the extra scan matched + # nothing and uv's architecture went unproven. + fail "no Mach-O outside $root was scanned, so uv and the venv's base interpreter escaped the check" + elif [ -n "$bad_arch" ]; then + fail "Mach-O is not $want, so it runs here only under Rosetta 2, which a fresh Mac does not have:$bad_arch" + elif [ -n "$unsigned" ]; then + fail "unsigned Mach-O main executable, which arm64 macOS refuses to exec:$unsigned" + elif [ -n "$broken" ]; then + fail "Mach-O main executable carries a signature that does not verify:$broken" + else + ok "$n Mach-O files under $root, plus uv and the venv's base interpreter, are $want$([ "$want" = arm64 ] && echo "; all $nexe main executable(s) signed")" + fi + fi + ;; + + *) + fail "unknown check '$check'" + ;; + esac +done + +exit "$rc" diff --git a/.github/scripts/clean-machine-env.sh b/.github/scripts/clean-machine-env.sh new file mode 100755 index 0000000000..6890f2f31e --- /dev/null +++ b/.github/scripts/clean-machine-env.sh @@ -0,0 +1,223 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +# +# Simulate a virgin developer machine on a GitHub-hosted runner. Two modes, because +# "the tool is absent" and "the installer never called the tool" need different +# mechanisms: +# +# mask Make the toolchain genuinely ABSENT: scrub PATH to OS defaults and (with +# --remove) move the real toolchain aside so `command -v git` correctly +# FAILS. Deliberately no "poison shims": a failing shim is still FOUND by +# `command -v`, which reports the tool as present, the opposite of clean. +# trace Leave the toolchain working behind wrappers that log the call then exec +# the real binary, answering whether the installer ever REACHES for a +# compiler/git without changing behaviour. +# +# Writes shell exports to $CLEAN_ENV_FILE (default ./clean-machine.env) to `source`; +# nothing is exported globally, so other steps keep a normal environment. +# +# Usage: +# bash .github/scripts/clean-machine-env.sh mask [--remove] +# bash .github/scripts/clean-machine-env.sh trace +# source ./clean-machine.env +set -uo pipefail + +MODE="${1:-}" +REMOVE=0 +[ "${2:-}" = "--remove" ] && REMOVE=1 + +case "$MODE" in + mask|trace) ;; + *) echo "usage: $0 {mask|trace} [--remove]" >&2; exit 2 ;; +esac + +OS="$(uname -s)" +WORK="${CLEAN_MACHINE_DIR:-$PWD/.clean-machine}" +ENV_FILE="${CLEAN_ENV_FILE:-$PWD/clean-machine.env}" +TRACE="$WORK/tool-invocations.log" +BIN="$WORK/bin" +RESTORE="$WORK/restore.sh" +mkdir -p "$BIN" +: > "$TRACE" +: > "$ENV_FILE" +printf '#!/usr/bin/env bash\n# Undo clean-machine-env.sh --remove. Safe to run twice.\nset -uo pipefail\n' > "$RESTORE" +chmod +x "$RESTORE" + +# The toolchain we care about: a consumer install must need none of it. +TOOLS="xcode-select xcrun clang clang++ cc c++ gcc g++ git cmake make brew ninja cargo rustc" + +note() { echo "[clean-machine] $*"; } + +# Move a path aside and record the reverse in restore.sh. PATH scrubbing only HIDES +# these; uv, the py launcher and framework lookups find them regardless, so absence +# has to be real. The restore line is guarded: the install may have recreated the +# path, and an unguarded `mv` would bury the original inside it. +mask_aside() { + local src="$1" dst="${2:-$1.masked}" as="" + [ -e "$src" ] || return 0 + [ -w "$(dirname "$src")" ] || as="sudo" + if $as mv "$src" "$dst" 2>/dev/null; then + note "moved $src aside" + printf "[ -e '%s' ] || %s mv '%s' '%s' 2>/dev/null || true\n" "$src" "$as" "$dst" "$src" >> "$RESTORE" + else + note "WARN could not move $src" + fi +} + +# ── PATH scrub ──────────────────────────────────────────────────────────────── +# Keep only OS-default system dirs: drops Homebrew, the hosted Python toolcache, +# setup-* shims, pipx, cargo and every other preinstalled developer dir. +scrub_path() { + local keep out="" + if [ "$OS" = "Darwin" ]; then + keep="/usr/bin:/bin:/usr/sbin:/sbin" + else + keep="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + fi + local IFS=":" + for d in $keep; do + [ -d "$d" ] && out="${out:+$out:}$d" + done + echo "$out" +} + +# ── mask ────────────────────────────────────────────────────────────────────── +if [ "$MODE" = "mask" ]; then + NEWPATH="$(scrub_path)" + { + echo "export PATH='$NEWPATH'" + # UNSET, not a fake path: `xcode-select -p` honours DEVELOPER_DIR and prints it + # verbatim with exit 0, so a nonexistent dir makes the probe SUCCEED. On a clean + # Mac it is unset and the missing xcode_select_link is what makes the probe fail. + echo "unset DEVELOPER_DIR || true" + echo "unset SDKROOT CC CXX CFLAGS CXXFLAGS LDFLAGS CMAKE_GENERATOR CMAKE_PREFIX_PATH || true" + echo "export HOMEBREW_NO_AUTO_UPDATE=1" + echo "export UNSLOTH_CLEAN_MACHINE=1" + } >> "$ENV_FILE" + + if [ "$REMOVE" = "1" ] && [ "$OS" = "Darwin" ]; then + # Best effort, each step independent and recorded in restore.sh so an + # `if: always()` step can put the runner back. `xcode-select -p` reads + # xcode_select_link, so removing it reproduces a virgin Mac's gate; + # `xcode-select --reset` is NOT enough, it can reselect a full Xcode.app. + if [ -e /var/db/xcode_select_link ]; then + if sudo rm -f /var/db/xcode_select_link 2>/dev/null; then + note "removed /var/db/xcode_select_link" + echo "sudo xcode-select --switch /Library/Developer/CommandLineTools 2>/dev/null || true" >> "$RESTORE" + else + note "WARN could not remove /var/db/xcode_select_link" + fi + fi + # Moving the CLT dir aside turns /usr/bin/{cc,clang,git} into dead shims, so the + # run also proves the install needs no compiler at all. + if [ -d /Library/Developer/CommandLineTools ]; then + if sudo mv /Library/Developer/CommandLineTools /Library/Developer/CommandLineTools.masked 2>/dev/null; then + note "moved CommandLineTools aside" + echo "sudo mv /Library/Developer/CommandLineTools.masked /Library/Developer/CommandLineTools 2>/dev/null || true" >> "$RESTORE" + else + note "WARN could not move CommandLineTools" + fi + fi + # Xcode.app must go too: with the link removed AND CommandLineTools moved, + # `xcode-select -p` still succeeds, falling through to the image's Xcode bundle + # (observed: /Applications/Xcode_16.4.app/Contents/Developer), which re-arms + # /usr/bin/{git,cc}. A rename is instant whatever the bundle size. + for app in /Applications/Xcode*.app; do + [ -d "$app" ] || continue + if sudo mv "$app" "${app}.masked" 2>/dev/null; then + note "moved $(basename "$app") aside" + echo "sudo mv '${app}.masked' '$app' 2>/dev/null || true" >> "$RESTORE" + else + note "WARN could not move $app" + fi + done + # /usr/local EXISTS on a factory-fresh Mac: a SIP-exempt firmlink, and empty. What + # is absent is its CONTENTS, /usr/local/bin included, so empty it rather than remove + # it. Before the Homebrew block below, so /usr/local/Homebrew is stashed once, with + # one restore line, in the right order. + if [ -d /usr/local ]; then + STASH="$WORK/usr-local" + mkdir -p "$STASH" + for entry in /usr/local/* /usr/local/.[!.]*; do + [ -e "$entry" ] || continue + base="$(basename "$entry")" + if sudo mv "$entry" "$STASH/$base" 2>/dev/null; then + note "emptied /usr/local/$base" + printf "[ -e '/usr/local/%s' ] || sudo mv '%s/%s' '/usr/local/%s' 2>/dev/null || true\n" \ + "$base" "$STASH" "$base" "$base" >> "$RESTORE" + else + note "WARN could not move $entry" + fi + done + fi + # The hosted toolcache and the python.org framework are what a PATH scrub cannot + # reach: uv discovers interpreters by probing well-known locations. + mask_aside "${AGENT_TOOLSDIRECTORY:-$HOME/hostedtoolcache}" + mask_aside /Library/Frameworks/Python.framework + # Developer dotdirs and caches. A virgin $HOME has none of these, and a populated + # uv/pip cache can satisfy a resolution that would fail on a user's machine. + for d in .cargo .rustup .nvm .rbenv .pyenv .local .cache \ + Library/Caches/uv Library/Caches/pip Library/Caches/Homebrew; do + mask_aside "$HOME/$d" + done + for brewdir in /opt/homebrew /usr/local/Homebrew; do + if [ -d "$brewdir" ]; then + if sudo mv "$brewdir" "${brewdir}.masked" 2>/dev/null; then + note "moved $brewdir aside" + echo "sudo mv '${brewdir}.masked' '$brewdir' 2>/dev/null || true" >> "$RESTORE" + else + note "WARN could not move $brewdir" + fi + fi + done + fi + + if [ "$REMOVE" = "1" ] && [ "$OS" = "Linux" ]; then + # A hosted Linux runner keeps git, gcc, cmake and make in /usr/bin, which the PATH + # scrub has to keep, so absence must be made real: move the resolved binaries aside + # (recorded in restore.sh). Versioned siblings like gcc-11 survive, but a consumer + # install invokes the unsuffixed names, which is what `absent` checks. + for tool in $TOOLS; do + # Repeat per tool: a runner can carry the same name in /usr/bin and + # /usr/local/bin, and moving only the first leaves the second on PATH. + for _ in 1 2 3 4; do + real="$(command -v "$tool" 2>/dev/null || true)" + [ -n "$real" ] && [ -e "$real" ] || break + if sudo mv "$real" "$real.masked" 2>/dev/null; then + note "moved $real aside" + echo "sudo mv '$real.masked' '$real' 2>/dev/null || true" >> "$RESTORE" + else + note "WARN could not move $real" + break + fi + done + done + fi +fi + +# ── trace ───────────────────────────────────────────────────────────────────── +if [ "$MODE" = "trace" ]; then + for tool in $TOOLS; do + real="$(command -v "$tool" 2>/dev/null || true)" + [ -n "$real" ] || continue + # Logs the call then execs the REAL binary: behaviour unchanged, so the trace + # answers "did the installer reach for this?" honestly. + cat > "$BIN/$tool" <> "$TRACE" +exec "$real" "\$@" +WRAP + chmod +x "$BIN/$tool" + done + { + echo "export PATH='$BIN:$PATH'" + echo "export UNSLOTH_TOOL_TRACE='$TRACE'" + echo "export UNSLOTH_CLEAN_MACHINE=trace" + } >> "$ENV_FILE" +fi + +note "mode=$MODE remove=$REMOVE" +note "env file: $ENV_FILE" +note "trace: $TRACE" +note "restore: $RESTORE" diff --git a/.github/scripts/ensure-docker-daemon.ps1 b/.github/scripts/ensure-docker-daemon.ps1 new file mode 100644 index 0000000000..50a2b5cc56 --- /dev/null +++ b/.github/scripts/ensure-docker-daemon.ps1 @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Waits for the Windows Docker daemon on a hosted runner, starting the service if +# it is installed but not running. +# +# Docker is installed on every windows-2022 image (runner-images uses Microsoft's +# install-docker-ce.ps1 without -HyperV, so the daemon serves WINDOWS containers) but +# is not always RUNNING when a job starts: a spike run died 21s in with +# failed to connect to the docker API at npipe:////./pipe/docker_engine +# while a sibling job was fine. Without this wait that flake reads as "Windows +# containers are not available on hosted runners", the wrong conclusion entirely. + +[CmdletBinding()] +param([int] $TimeoutMinutes = 5) + +$deadline = (Get-Date).AddMinutes($TimeoutMinutes) +while ($true) { + docker info *>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Host "docker daemon is up" + break + } + if ((Get-Date) -ge $deadline) { + Write-Host "::error::the Docker daemon never became reachable within $TimeoutMinutes minutes" + Get-Service docker -ErrorAction SilentlyContinue | Format-List | Out-String | Write-Host + exit 1 + } + $svc = Get-Service -Name docker -ErrorAction SilentlyContinue + Write-Host "docker service status: $(if ($svc) { $svc.Status } else { 'NOT INSTALLED' }); retrying..." + if ($svc -and $svc.Status -ne 'Running') { + Start-Service docker -ErrorAction SilentlyContinue + } + Start-Sleep -Seconds 5 +} + +# The failing `docker info` probes leave $LASTEXITCODE non-zero and the runner appends +# `exit $LASTEXITCODE` to every pwsh step (actions/runner#351), so without this reset a +# successful wait still fails the step. +$global:LASTEXITCODE = 0 +exit 0 diff --git a/.github/scripts/virgin-windows-install.ps1 b/.github/scripts/virgin-windows-install.ps1 new file mode 100644 index 0000000000..2c0821ca62 --- /dev/null +++ b/.github/scripts/virgin-windows-install.ps1 @@ -0,0 +1,154 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Runs INSIDE a Windows container, after virgin-windows-probe.ps1 has proved there is no +# toolchain: install.ps1 the way a real user on a bare Windows box runs it, then the same +# assertions the hosted Windows leg makes. + +[CmdletBinding()] +param( + [string] $Installer = 'C:\ci\install.ps1', + [string] $LogPath = 'C:\ci-out\install.log', + [string] $Overlay = '' +) + +$ErrorActionPreference = 'Continue' +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null + +function Section($t) { Write-Host ""; Write-Host "=== $t ===" } + +# ── Environment the installer needs to be non-interactive ───────────────────── +Section 'install environment' +# install.ps1:2885-2888 prompts `Start Unsloth Studio now? [Y/n]` when +# [Environment]::UserInteractive is true and stdin is not redirected -- both hold under +# `docker exec` -- so without this the installer BLOCKS FOREVER on Read-Host and the job +# dies on timeout with no diagnosis. +$env:UNSLOTH_SKIP_AUTOSTART = '1' +# install.ps1:254/258 joins $env:USERPROFILE with no null guard. An explicit root also +# keeps the container's state under one directory. +$env:UNSLOTH_STUDIO_HOME = 'C:\studio-home' +$env:UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK = '1' +# Without this uv's output is discarded on success and the nobuild check below can only +# ever report "built: none". +$env:UNSLOTH_VERBOSE = '1' +if ($Overlay) { + $env:UNSLOTH_CI_SOURCE_OVERLAY = $Overlay + Write-Host "overlay: $Overlay" +} else { + Remove-Item Env:\UNSLOTH_CI_SOURCE_OVERLAY -ErrorAction SilentlyContinue + Write-Host "overlay: (none -- tests the released wheel)" +} +foreach ($v in 'UNSLOTH_STUDIO_HOME', 'UNSLOTH_SKIP_AUTOSTART', 'UNSLOTH_VERBOSE', 'UNSLOTH_CI_SOURCE_OVERLAY') { + Write-Host (" {0,-32} {1}" -f $v, [System.Environment]::GetEnvironmentVariable($v)) +} + +# NOTE: deliberately NOT setting [Net.ServicePointManager]::SecurityProtocol here. +# install.ps1 does not set it either, so setting it in the harness would hide a real +# installer bug. The probe already reported whether the default negotiates TLS 1.2. + +# ── Run the installer exactly as the desktop launches it ────────────────────── +Section 'install' +if (-not (Test-Path -LiteralPath $Installer)) { + Write-Host "::error::installer not found at $Installer" + exit 1 +} +Write-Host "installer: $Installer ($((Get-Content -LiteralPath $Installer).Count) lines)" +$sw = [System.Diagnostics.Stopwatch]::StartNew() + +# A child powershell.exe, not dot-sourcing: same shape as install.rs:325-339, and it +# gives a real process exit code instead of whatever the last statement returned. +& powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File $Installer *>&1 | Tee-Object -FilePath $LogPath +$rc = $LASTEXITCODE +$sw.Stop() +Write-Host "" +Write-Host "installer exit code: $rc (after $([int]$sw.Elapsed.TotalSeconds)s)" + +# ── Assertions ──────────────────────────────────────────────────────────────── +$failures = @() +$venv = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio' +$venvPy = Join-Path $venv 'Scripts\python.exe' + +Section 'assert: the install produced something usable' +if ($rc -ne 0) { + $failures += "installer exited $rc" +} else { + # As the Linux leg: an installer that exits 0 having done nothing must not pass. + if (-not (Test-Path -LiteralPath $venvPy)) { + $failures += "installer exited 0 but left no managed Python at $venvPy" + Get-ChildItem -Path $env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue | Format-Table | Out-String | Write-Host + } else { + Write-Host "managed python: $venvPy" + & $venvPy -V + } + foreach ($cli in (Join-Path $venv 'Scripts\unsloth.exe'), (Join-Path $env:UNSLOTH_STUDIO_HOME 'bin\unsloth.exe')) { + if (Test-Path -LiteralPath $cli) { Write-Host "unsloth CLI: $cli" } + else { $failures += "installer exited 0 but left no unsloth CLI at $cli" } + } +} + +Section 'assert: torch imports' +# On the hosted runner this proves less than it looks: the image ships the VC++ +# 2015-2022 runtime in System32, so Test-VCRedistInstalled (setup.ps1:875) +# short-circuits before it needs winget. THIS container is the first environment where +# that is not true, so a failure here is a genuine finding about bare Windows. +if (Test-Path -LiteralPath $venvPy) { + foreach ($dll in 'vcruntime140.dll', 'vcruntime140_1.dll', 'msvcp140.dll') { + $p = Join-Path $env:WINDIR "System32\$dll" + Write-Host (" System32\{0,-20} {1}" -f $dll, $(if (Test-Path $p) { 'PRESENT' } else { 'ABSENT' })) + } + & $venvPy -c "import ctypes.util; print('find_library(vcruntime140):', ctypes.util.find_library('vcruntime140'))" + & $venvPy -c "import torch; print('torch', torch.__version__)" + if ($LASTEXITCODE -ne 0) { + $failures += "torch failed to import from the managed Python (VC++ runtime missing?)" + } + $global:LASTEXITCODE = 0 +} else { + Write-Host "skipped: no managed Python" +} + +Section "assert: the installer took the no-winget path" +if (Test-Path -LiteralPath $LogPath) { + # install.ps1:1098, the no-winget branch. A container has no Microsoft Store and so + # no App Installer, which puts the fallback path (python.org + astral.sh) under + # test -- the whole reason a container is a good harness. + $noWinget = 'will require Python + uv to be already installed' + if (Select-String -Path $LogPath -Pattern $noWinget -SimpleMatch -Quiet) { + Write-Host "confirmed: installer reported winget as unavailable and used the fallback path" + } else { + $failures += "installer never reported winget as unavailable; it did not take the no-winget path" + } +} + +if ($Overlay -and $rc -eq 0) { + Section 'assert: this ref was really put under test' + if (Select-String -Path $LogPath -Pattern 'CI: overlaying source checkout' -SimpleMatch -Quiet) { + Write-Host "overlay applied; this leg exercised this ref's Python" + } else { + $failures += "leg is marked overlay but the installer never overlaid the checkout, so it only tested the released package" + } +} + +Section 'assert: no non-allowlisted source build' +# Shared with the hosted Windows legs so the sdist allowlist lives in one place; it +# prints its own diagnosis, so only the verdict is folded in here. +$nobuild = Join-Path $PSScriptRoot 'assert-nobuild.ps1' +if (-not (Test-Path -LiteralPath $nobuild)) { + $failures += "assert-nobuild.ps1 is missing next to this script, so the no-build contract went unchecked" +} else { + & $nobuild -LogPath $LogPath + if ($LASTEXITCODE -ne 0) { $failures += "a non-allowlisted source build appears in the install log" } +} + +# ── Verdict ─────────────────────────────────────────────────────────────────── +Section 'verdict' +if ($failures.Count -gt 0) { + Write-Host "---- last 60 lines of the install log ----" + Get-Content -LiteralPath $LogPath -Tail 60 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $_" } + Write-Host "-----------------------------------------" + foreach ($f in $failures) { Write-Host "::error::$f" } + Write-Host "VIRGIN WINDOWS CONTAINER INSTALL FAILED ($($failures.Count) problem(s))" + exit 1 +} +Write-Host "VIRGIN WINDOWS CONTAINER INSTALL PASSED" +exit 0 diff --git a/.github/scripts/virgin-windows-probe.ps1 b/.github/scripts/virgin-windows-probe.ps1 new file mode 100644 index 0000000000..a305d2dcae --- /dev/null +++ b/.github/scripts/virgin-windows-probe.ps1 @@ -0,0 +1,178 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Runs INSIDE a Windows container, proving the environment is genuinely virgin BEFORE +# anything is installed into it. This is the entire point of the container lane: the +# hosted-runner Windows legs of clean-machine-install-ci.yml only simulate absence +# (rename the toolcache Python dir, scrub the Machine and User registry PATH), while +# this asserts real absence on an image that never had a toolchain. Without it the lane +# proves nothing the masked legs did not already prove. + +$ErrorActionPreference = 'Continue' +$failures = @() + +function Section($t) { Write-Host ""; Write-Host "=== $t ===" } + +# ── The interpreter itself ──────────────────────────────────────────────────── +# install.ps1 must run under Windows PowerShell 5.1, which is what a real Windows +# box ships. pwsh 7 is a runner-image extra no user is promised. nanoserver has +# NEITHER, which is why this lane is on servercore. +Section 'interpreter' +Write-Host "PSVersion : $($PSVersionTable.PSVersion)" +Write-Host "PSEdition : $($PSVersionTable.PSEdition)" +Write-Host "CLRVersion : $($PSVersionTable.CLRVersion)" +Write-Host "Host : $($Host.Name)" +if ($PSVersionTable.PSEdition -ne 'Desktop') { + $failures += "PSEdition is '$($PSVersionTable.PSEdition)', not Desktop -- this is not Windows PowerShell 5.1" +} +if ($PSVersionTable.PSVersion.Major -ne 5) { + $failures += "PSVersion is $($PSVersionTable.PSVersion), not 5.x" +} + +Section 'operating system' +cmd /c ver +$cv = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction SilentlyContinue +if ($cv) { + Write-Host "ProductName : $($cv.ProductName)" + Write-Host "EditionID : $($cv.EditionID)" + Write-Host "InstallationType: $($cv.InstallationType)" + Write-Host "CurrentBuild : $($cv.CurrentBuild).$($cv.UBR)" +} +Write-Host "USERNAME : $env:USERNAME" +Write-Host "USERPROFILE : $env:USERPROFILE" +Write-Host "LOCALAPPDATA : $env:LOCALAPPDATA" +Write-Host "PROCESSOR_ARCH : $env:PROCESSOR_ARCHITECTURE" + +# install.ps1:254/258 does Join-Path $env:USERPROFILE ".unsloth\studio" with no null +# guard, so an unset USERPROFILE aborts under ErrorActionPreference=Stop. The lane sets +# UNSLOTH_STUDIO_HOME, but record whether a bare container would have survived without. +if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + Write-Host "::warning::USERPROFILE is unset in this container; install.ps1's default install root would abort" +} + +# ── The assertion the whole lane exists for ─────────────────────────────────── +Section 'virginity: developer toolchain must be ABSENT' +# python/py/git/cmake/cl/winget are the six the task names. uv is added because +# install.ps1 would happily reuse a preinstalled one and skip its own bootstrap. +$mustBeAbsent = @('python', 'python3', 'py', 'git', 'cmake', 'cl', 'winget', 'uv') +foreach ($t in $mustBeAbsent) { + $c = Get-Command $t -ErrorAction SilentlyContinue + $where = if ($c) { $c.Source } else { 'ABSENT' } + Write-Host (" {0,-10} {1}" -f $t, $where) + if ($c) { $failures += "$t is present at $($c.Source) -- this container is NOT virgin" } +} + +Section 'informational: present but not a developer toolchain' +# OS components, not a toolchain. curl.exe and tar.exe ship in System32 on Server 2022 +# and are the only transport into a container with no git; naming them keeps the +# premise honest rather than silently relying on them. +foreach ($t in 'cmd', 'powershell', 'curl', 'tar', 'certutil', 'msiexec', 'reg', 'where', 'pwsh', 'node', 'npm', 'msbuild', 'dotnet', 'gcc') { + $c = Get-Command $t -ErrorAction SilentlyContinue + Write-Host (" {0,-10} {1}" -f $t, $(if ($c) { $c.Source } else { 'ABSENT' })) +} + +Section 'virginity: no toolchain on disk either' +# A binary can be off PATH and still be found by uv's interpreter discovery or py.exe's +# registry view -- exactly how the hosted Windows leg once reported `python ABSENT` and +# then installed with the runner's 3.13.14. So check disk and registry too. +$badPaths = @( + 'C:\Python27', 'C:\Python3*', 'C:\Program Files\Python*', 'C:\Program Files (x86)\Python*', + 'C:\Program Files\Git', 'C:\Program Files\CMake', 'C:\Program Files\Microsoft Visual Studio', + 'C:\Program Files (x86)\Microsoft Visual Studio', 'C:\hostedtoolcache', 'C:\ProgramData\chocolatey' +) +foreach ($p in $badPaths) { + # Wildcards can match several dirs; take the first so the message names a real + # path instead of stringifying an array. + $hit = @(Get-Item -Path $p -ErrorAction SilentlyContinue) | Select-Object -First 1 + if ($hit) { + Write-Host " PRESENT $($hit.FullName)" + $failures += "toolchain directory exists on disk: $($hit.FullName)" + } else { + Write-Host " absent $p" + } +} + +$pyReg = @('HKLM:\SOFTWARE\Python', 'HKCU:\SOFTWARE\Python') +foreach ($k in $pyReg) { + if (Test-Path $k) { + Write-Host " PRESENT $k" + $failures += "a registered Python install exists at $k" + } else { + Write-Host " absent $k" + } +} + +Section 'PATH as the container sees it' +Write-Host "Process PATH:" +($env:PATH -split ';') | Where-Object { $_ } | ForEach-Object { Write-Host " $_" } +foreach ($scope in 'Machine', 'User') { + Write-Host "$scope PATH: $([System.Environment]::GetEnvironmentVariable('Path', $scope))" +} + +# ── The VC++ runtime question the hosted leg cannot answer ──────────────────── +Section 'VC++ runtime (honest measurement)' +# The hosted image ships the VC++ 2015-2022 runtime in System32 and cannot lose it +# without breaking the runner (see the HONESTY NOTE in clean-machine-install-ci.yml), +# so `import torch` succeeding there does NOT prove a no-winget machine has the +# runtime. This container is the only environment in CI that can answer it, so their +# absence is asserted, not merely recorded: if a future base image starts shipping +# them the lane silently degrades into another masked leg. +foreach ($dll in 'vcruntime140.dll', 'vcruntime140_1.dll', 'msvcp140.dll') { + $p = Join-Path $env:WINDIR "System32\$dll" + $present = Test-Path $p + Write-Host (" {0,-20} {1}" -f $dll, $(if ($present) { 'PRESENT' } else { 'ABSENT' })) + if ($present) { $failures += "System32\$dll is present -- this image already ships the VC++ runtime, which is the one thing the hosted runner cannot un-ship" } +} +foreach ($k in 'HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64') { + $r = Get-ItemProperty $k -ErrorAction SilentlyContinue + Write-Host (" {0} -> {1}" -f $k, $(if ($r) { "Installed=$($r.Installed) $($r.Major).$($r.Minor)" } else { 'absent' })) +} + +# ── Can the installer's transport work at all here? ─────────────────────────── +Section 'outbound HTTPS and TLS' +# install.ps1 never sets [Net.ServicePointManager]::SecurityProtocol, so it inherits the +# .NET Framework default. Test the DEFAULT first: if that fails and Tls12 works, the +# installer has a real portability bug on hardened images, not a container quirk. +Write-Host "default SecurityProtocol: $([Net.ServicePointManager]::SecurityProtocol)" +$probeUrls = @( + 'https://www.python.org/ftp/python/', + 'https://astral.sh/uv/install.ps1', + 'https://pypi.org/simple/', + 'https://aka.ms/vs/17/release/vc_redist.x64.exe' +) +$defaultOk = @{} +foreach ($u in $probeUrls) { + try { + $null = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 60 -Method Head -ErrorAction Stop + Write-Host " OK (default TLS) $u"; $defaultOk[$u] = $true + } catch { + Write-Host " FAIL (default TLS) $u -- $($_.Exception.Message)"; $defaultOk[$u] = $false + } +} +if ($defaultOk.Values -contains $false) { + Write-Host "retrying the failures with an explicit Tls12..." + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + foreach ($u in $probeUrls) { + if ($defaultOk[$u]) { continue } + try { + $null = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 60 -Method Head -ErrorAction Stop + Write-Host " OK (Tls12) $u" + Write-Host "::warning::$u needs an explicit Tls12; install.ps1 never sets SecurityProtocol, so this is a real installer portability gap" + } catch { + Write-Host " FAIL (Tls12) $u -- $($_.Exception.Message)" + $failures += "no outbound HTTPS to $u even with Tls12 -- the container cannot reach the installer's download hosts" + } + } +} + +# ── Verdict ─────────────────────────────────────────────────────────────────── +Section 'verdict' +if ($failures.Count -gt 0) { + foreach ($f in $failures) { Write-Host "::error::$f" } + Write-Host "VIRGINITY ASSERTION FAILED ($($failures.Count) problem(s))" + exit 1 +} +Write-Host "VIRGINITY ASSERTION PASSED" +Write-Host "no python, py, git, cmake, cl, winget or uv on PATH, on disk, or in the registry" +exit 0 diff --git a/.github/workflows/clean-machine-install-ci.yml b/.github/workflows/clean-machine-install-ci.yml new file mode 100644 index 0000000000..010bd91d25 --- /dev/null +++ b/.github/workflows/clean-machine-install-ci.yml @@ -0,0 +1,1728 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Proves Unsloth installs on a machine that has never seen a developer toolchain. +# +# Why: studio-mac-install-matrix.yml runs `install.sh --local --no-torch` on runners +# with Xcode CLT selected AND actions/setup-python preinstalled, so the macOS +# dependency gate never fires there -- and `--local` is precisely the mode that +# legitimately needs git. A brand-new Mac hits a hard `exit 1` no CI job covered. +# +# Hosted runners are developer machines, so each job simulates absence, two ways (see +# .github/scripts/clean-machine-env.sh): +# mask -> the toolchain is genuinely unusable; does the install still work? +# trace -> the toolchain works but is logged; does the installer ever call it? +# Linux is the exception: containers are genuinely clean. +# +# ── What `overlay` decides ──────────────────────────────────────────────────── +# install.sh / install.ps1 come from this ref but install unsloth FROM PyPI, the +# consumer path, which has to stay that way -- so everything Python-side would be the +# RELEASED wheel's (setup.sh, setup.ps1, install_python_stack.py and every +# requirements/constraints file they reach via Path(__file__)) and a branch changing +# any of them would get a green run proving nothing. `overlay: true` legs therefore +# re-point the venv at this ref before studio setup, via UNSLOTH_CI_SOURCE_OVERLAY: a +# `--no-deps` editable install of the checkout, so `import studio` resolves to the +# working tree and the setup-script lookup finds this ref's setup.sh / setup.ps1. NOT +# `install.sh --local`, which also pulls `unsloth-zoo @ git+https://...` and so needs +# the git these legs remove; an editable overlay resolves and clones nothing. +# +# Legs left on `overlay: false`: +# mac */mask/pipe the `curl | sh` shape a user runs, kept end-to-end on the +# released package so a broken PyPI release still shows up. +# mac macos-14/trace `notools` asserts the installer never reaches for git, and the +# editable build calls `git rev-parse` / `git archive` itself via +# setuptools-scm's file finder, answering the leg's own question. +# linux ubuntu2404-nonroot-notransport dies at the elevation gate before a venv exists. +# wsl only install.sh is copied in; no source tree inside WSL. + +name: Clean machine install + +on: + pull_request: + paths: + - 'install.sh' + - 'install.ps1' + - 'studio/setup.sh' + - 'studio/setup.ps1' + - 'studio/install_python_stack.py' + # setup.sh (727) and setup.ps1 (2343, 3630, 3916) call these directly and the + # overlay makes them THIS ref's code, so they decide whether a clean machine gets + # a native prebuilt or a toolchain-dependent fallback. + - 'studio/install_*_prebuilt.py' + - 'studio/prebuilt_core.py' + - 'studio/node_prebuilt_pins.json' + # The overlay exists so a constraints or requirements change is exercised here + # (see the header). update-smoke cannot stand in: it starts from a preinstalled + # Python and full developer tooling. + - 'studio/backend/requirements/**' + - '.github/scripts/clean-machine-*.sh' + - '.github/scripts/assert-llama-loads.sh' + # The virgin Windows container lane lives in this workflow too. + - '.github/scripts/virgin-windows-*.ps1' + - '.github/scripts/ensure-docker-daemon.ps1' + - '.github/scripts/assert-nobuild.ps1' + - '.github/workflows/clean-machine-install-ci.yml' + push: + branches: [main] + # Same list as the PR filter: without it a direct push to main touching any of + # these skipped the workflow and the post-merge backstop never happened. + paths: + - 'install.sh' + - 'install.ps1' + - 'studio/setup.sh' + - 'studio/setup.ps1' + - 'studio/install_python_stack.py' + - 'studio/install_*_prebuilt.py' + - 'studio/prebuilt_core.py' + - 'studio/node_prebuilt_pins.json' + - 'studio/backend/requirements/**' + - '.github/scripts/clean-machine-*.sh' + - '.github/scripts/assert-llama-loads.sh' + - '.github/scripts/virgin-windows-*.ps1' + - '.github/scripts/ensure-docker-daemon.ps1' + - '.github/scripts/assert-nobuild.ps1' + - '.github/workflows/clean-machine-install-ci.yml' + workflow_dispatch: + inputs: + installer_source: + description: 'published = curl unsloth.ai/install.sh, tree = the checked-out script' + type: choice + options: [tree, published] + default: tree + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # Keep every install inside the workspace so a leg cannot inherit another's state. + UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home + # No wildcard bind -> no ifconfig.me / check-host.net calls on the startup path. + UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1' + # Without this run_install_cmd (install.sh:193-243) sends every `uv pip install` to a + # temp file and DELETES it on success, so `nobuild` can only report "built: none". + UNSLOTH_VERBOSE: '1' + +jobs: + # ── macOS: the reported failure ──────────────────────────────────────────── + macos: + name: mac ${{ matrix.os }} / ${{ matrix.mode }} / ${{ matrix.delivery }}${{ matrix.flags && format(' {0}', matrix.flags) || '' }} + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + continue-on-error: ${{ matrix.experimental }} + # Explicit legs, not a full cross-product: the interesting dimensions are (does + # the toolchain exist) x (how the script is delivered), not every pairing. + strategy: + fail-fast: false + matrix: + include: + # `overlay` decides whether this ref's Python is under test at all; see the + # header. The pipe legs stay on the released package on purpose. + # + # The reported failure, in the shape users run it, with torch because that is + # what a consumer gets. + - {os: macos-14, mode: mask, delivery: pipe, flags: '', experimental: false, overlay: false} + - {os: macos-14, mode: mask, delivery: file, flags: '', experimental: false, overlay: true} + # What the desktop app runs: no tty, stdin closed, TAURI markers on. + - {os: macos-14, mode: mask, delivery: tauri, flags: '', experimental: false, overlay: true} + # Toolchain present but logged: does the installer ever reach for it? No + # overlay: the editable build calls git itself (setuptools-scm), planting the + # very evidence `notools` looks for. + - {os: macos-14, mode: trace, delivery: file, flags: '', experimental: false, overlay: false} + # --no-torch is the one macOS path that can still want a compiler + # (sentencepiece has no guaranteed cp313 arm64 wheel), so probe it apart from + # the default path rather than let it hide the gate under test. + - {os: macos-14, mode: mask, delivery: file, flags: '--no-torch', experimental: true, overlay: true} + - {os: macos-15, mode: mask, delivery: pipe, flags: '', experimental: false, overlay: false} + - {os: macos-26, mode: mask, delivery: file, flags: '', experimental: true, overlay: true} + # Intel pins python 3.12 and its /usr/bin/git is not CLT-provided, so it + # survives masking. Informational only. + - {os: macos-15-intel, mode: mask, delivery: file, flags: '', experimental: true, overlay: true, allow_working: 'git'} + + steps: + # checkout FIRST: it needs a working git, which masking then takes away. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # No actions/setup-python on purpose: install.sh must bring its own uv-managed + # CPython, as it must on a user's machine. + + - name: Record the pre-masking toolchain + run: | + { + echo "xcode-select -p : $(xcode-select -p 2>&1 || true)" + echo "git : $(command -v git || echo none)" + echo "brew : $(command -v brew || echo none)" + echo "cmake : $(command -v cmake || echo none)" + echo "python3 : $(command -v python3 || echo none)" + # Neither is documented for these images and both change what a binary is + # allowed to do. One line settles it for anyone reading the artifact. + echo "spctl --status : $(spctl --status 2>&1 || true)" + echo "csrutil status : $(csrutil status 2>&1 || true)" + } | tee runner-baseline.txt + + - name: Simulate a clean machine (${{ matrix.mode }}) + run: | + mkdir -p logs + if [ "${{ matrix.mode }}" = "mask" ]; then + bash .github/scripts/clean-machine-env.sh mask --remove + else + bash .github/scripts/clean-machine-env.sh trace + fi + + - name: Verify the simulation actually took effect + if: matrix.mode == 'mask' + run: | + set -a; . ./clean-machine.env; set +a + UNSLOTH_CLEAN_ALLOW_WORKING='${{ matrix.allow_working }}' \ + bash .github/scripts/clean-machine-assert.sh absent + + - name: Verify the trace actually records + if: matrix.mode == 'trace' + run: | + # `notools` reads an absence, so a shim dir that never reached PATH looks + # exactly like an installer that touched nothing and the one leg carrying that + # assertion would pass whatever the installer did. Prove the wrapper records + # before trusting an empty file. macOS never probes git off the --local path, + # so the call has to be explicit. + set -a; . ./clean-machine.env; set +a + [ -n "$UNSLOTH_TOOL_TRACE" ] || { echo "::error::trace mode set no UNSLOTH_TOOL_TRACE"; exit 1; } + git --version >/dev/null 2>&1 || true + if ! grep -q "^git[[:space:]]" "$UNSLOTH_TOOL_TRACE"; then + echo "::error::the trace wrapper did not record a git call, so notools proves nothing" + echo "PATH=$PATH"; command -v git; cat "$UNSLOTH_TOOL_TRACE" || true + exit 1 + fi + echo "trace wrapper records; clearing the self-test entry" + : > "$UNSLOTH_TOOL_TRACE" + + - name: Install + id: install + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Withheld on PR: this step runs checked-out PR code. + HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} + # Empty, and so ignored by install.sh, on the non-overlay legs. Empty for + # `installer_source: published` too: the script under test is then + # production's, which has no such hook, and overlaying this ref's Python onto + # it would report on neither honestly. + UNSLOTH_CI_SOURCE_OVERLAY: ${{ matrix.overlay && inputs.installer_source != 'published' && github.workspace || '' }} + run: | + set -a; . ./clean-machine.env; set +a + set -o pipefail + rc=0 + FLAGS="${{ matrix.flags }}" + # A consumer has no CI=true, no GITHUB_*, no RUNNER_*: branching on any of them + # is a hidden dependency nobody outside CI exercises. Scoped to the installer's + # own process, so $GITHUB_OUTPUT below still resolves. `case` rather than + # `sed`, whose BRE has no \| alternation on macOS. + CLEAN_ENV="" + for v in $(env | cut -d= -f1); do + case "$v" in CI|GITHUB_*|RUNNER_*) CLEAN_ENV="$CLEAN_ENV -u $v" ;; esac + done + echo "unset for the installer:$CLEAN_ENV" + # A `published` dispatch asks whether unsloth.ai's script works, and only + # `pipe` honoured it, so six of eight macOS rows ran the checked-out script + # under the published label. Resolved once here for every delivery. Empty on + # pull_request/push, so automatic runs stay on this ref. + SCRIPT=install.sh + if [ "${{ inputs.installer_source }}" = "published" ]; then + curl -fsSL https://unsloth.ai/install.sh -o published-install.sh + SCRIPT=published-install.sh + echo "installer: published (unsloth.ai)" + else + echo "installer: this ref ($GITHUB_SHA)" + fi + case "${{ matrix.delivery }}" in + file) + # Isolates "installer logic broken" from "curl-pipe delivery broken". + env $CLEAN_ENV bash "$SCRIPT" $FLAGS 2>&1 | tee logs/install.log || rc=$? + ;; + pipe) + # The shape users actually run. install.sh is ~150KB of top-level + # statements, so an early `exit` leaves the writer with a closed pipe -> + # `curl: (56)`; piping a local file reproduces that faithfully without + # depending on unsloth.ai being current. The published case re-fetches + # rather than piping $SCRIPT: the live transport is half of what this + # delivery tests. + if [ "${{ inputs.installer_source }}" = "published" ]; then + curl -fsSL https://unsloth.ai/install.sh | env $CLEAN_ENV sh -s -- $FLAGS 2>&1 | tee logs/install.log || rc=$? + else + # `sh -s --` with no further args passes an empty positional, so add + # the separator only when there are flags. + if [ -n "$FLAGS" ]; then + cat install.sh | env $CLEAN_ENV sh -s -- $FLAGS 2>&1 | tee logs/install.log || rc=$? + else + cat install.sh | env $CLEAN_ENV sh 2>&1 | tee logs/install.log || rc=$? + fi + fi + ;; + tauri) + # Exactly how the desktop app invokes it: no tty, stdin closed. --tauri + # rejects a custom UNSLOTH_STUDIO_HOME outright (it still uses the legacy + # ~/.unsloth/studio root), so the workspace-scoped value every other leg + # relies on must be dropped or the installer exits before doing any work. + # The runner is ephemeral, so the real home is as disposable. + env -u UNSLOTH_STUDIO_HOME $CLEAN_ENV \ + bash "$SCRIPT" --tauri $FLAGS < /dev/null 2>&1 | tee logs/install.log || rc=$? + ;; + esac + echo "install_rc=$rc" >> "$GITHUB_OUTPUT" + echo "installer exit code: $rc" + # The pipe legs expose curl:(56); surface it rather than leaving it buried in + # a 4000-line log. + if grep -qE "curl: \(5[36]\)|Failure writing output to destination" logs/install.log; then + echo "::warning::curl reported a broken pipe -- an early exit killed the reader" + fi + exit "$rc" + + # install.sh ignores an unset UNSLOTH_CI_SOURCE_OVERLAY, so a typo in the matrix or + # the expression silently puts every leg back on the released wheel. + - name: Assert this ref's Python was really put under test + if: matrix.overlay && inputs.installer_source != 'published' && steps.install.outcome == 'success' + run: | + grep -q "CI: overlaying source checkout" logs/install.log || { + echo "::error::this leg is marked overlay: true but the installer never overlaid the checkout, so it only tested the released package" + exit 1 + } + echo "overlay applied; this leg exercised this ref's Python" + + - name: Assert no source build and no toolchain use + if: always() && steps.install.outcome == 'success' + run: | + set -a; . ./clean-machine.env; set +a + checks="nobuild" + # `absent` ran only BEFORE the install, so an installer that quietly selected + # the CLT or installed a compiler left the leg green. Re-run it after. + [ "${{ matrix.mode }}" = "mask" ] && checks="$checks absent" + [ "${{ matrix.mode }}" = "trace" ] && checks="$checks notools" + UNSLOTH_CLEAN_ALLOW_WORKING='${{ matrix.allow_working }}' \ + bash .github/scripts/clean-machine-assert.sh $checks + + - name: Assert llama.cpp loads, and every downloaded Mach-O is native and signed + if: steps.install.outcome == 'success' + run: | + set -a; . ./clean-machine.env; set +a + # The tauri leg cannot honour UNSLOTH_STUDIO_HOME (see Install) and went to the + # legacy root, where llama.cpp sits at /llama.cpp and the venv at + # /studio: so ~/.unsloth, not ~/.unsloth/studio. + if [ "${{ matrix.delivery }}" = "tauri" ]; then + HOME_DIR="$HOME/.unsloth" + else + HOME_DIR="$UNSLOTH_STUDIO_HOME" + fi + STUDIO_HOME="$HOME_DIR" bash .github/scripts/assert-llama-loads.sh + # Rosetta 2 is on this runner and not on a fresh Mac, so llama-server launching + # above does not prove it would launch for a user. Assert the arch of every + # payload (llama.cpp, whisper.cpp, the Node prebuilt, uv) instead. + MACHO_ROOT="$HOME_DIR" bash .github/scripts/clean-machine-assert.sh macho + + - name: Restore the runner + if: always() + # `|| true` swallowed everything, a genuinely broken restore included. The file + # only exists once the strip step ran and an earlier step can fail before that, + # so skip explicitly when it is absent and let a real failure surface. + run: | + if [ -f .clean-machine/restore.sh ]; then + bash .clean-machine/restore.sh + else + echo "no .clean-machine/restore.sh: the toolchain was never stripped, nothing to restore" + fi + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + # Two rows differ only in `flags`, so flags must be in the name: artifacts are + # immutable per run and the second upload 409s. + name: clean-mac-${{ matrix.os }}-${{ matrix.mode }}-${{ matrix.delivery }}${{ matrix.flags && format('-{0}', matrix.flags) || '' }} + path: | + logs/ + runner-baseline.txt + clean-machine.env + .clean-machine/tool-invocations.log + retention-days: 7 + if-no-files-found: warn + + # ── Linux: genuinely clean, via containers ──────────────────────────────── + linux: + name: linux ${{ matrix.label }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.image }} + timeout-minutes: 40 + continue-on-error: ${{ matrix.experimental }} + # Container jobs default to `sh -e` (dash), where `set -o pipefail` is an "Illegal + # option" that kills the step before the installer starts. + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + include: + # Root + apt: install.sh's _smart_apt_install should self-heal from a base + # image with no curl, git, gcc or cmake at all. + - label: ubuntu2404-root + image: ubuntu:24.04 + runner: ubuntu-latest + experimental: false + overlay: true + - label: ubuntu2404-arm-root + image: ubuntu:24.04 + runner: ubuntu-24.04-arm + experimental: false + overlay: true + # No elevation, but WITH the transport the advertised one-liner needs: not + # root, no sudo anywhere on the image, no toolchain, ca-certificates + curl and + # nothing else. Since #7547 the optional set (cmake, git, build-essential, + # libcurl4-openssl-dev) never escalates, so this must install end to end off + # prebuilt llama.cpp, and until now nothing proved it. + # + # Gating, and overlay: true for the reason fedora41 is: the RELEASED + # install_python_stack.py has no "skip triton kernels when git is missing" + # guard, so without the overlay the run reaches the final step and dies there + # on pure release lag (staging run 30421021166: venv, frontend, torch and + # extras all fine, then `Installing triton kernels (pip) failed`). + - label: ubuntu2404-nonroot + image: ubuntu:24.04 + runner: ubuntu-latest + experimental: false + overlay: true + nonroot: true + # The same premise with the OTHER transport. install.sh's download() prefers + # curl and falls back to wget (729-738), _http_get does the same for the + # connectivity probe (1019-1027), the Radeon listing repeats it (3064-3067), + # and _check_linux_deps calls the transport missing only when BOTH are gone + # (2077-2079). So a box with wget and no curl -- a Debian netinst default, and + # every image where curl was deliberately removed -- is supported on paper and + # had never been run: the nonroot row above provisions ca-certificates AND + # curl, so curl won every probe and the wget branch was only ever reasoned from + # the code. This row asserts everything that one does, plus curl proved absent + # for the whole run rather than merely unused. + - label: ubuntu2404-nonroot-wget + image: ubuntu:24.04 + runner: ubuntu-latest + experimental: false + overlay: true + nonroot: true + wget_only: true + # No elevation AND no transport. apt is the only way to get curl and reaching + # apt needs elevation, so failing is correct; the point is to pin the exact + # message and prove it is actionable rather than a bare `curl: (56)`. No + # overlay: it never reaches a venv to overlay into. + - label: ubuntu2404-nonroot-notransport + image: ubuntu:24.04 + runner: ubuntu-latest + experimental: false + overlay: false + nonroot: true + no_transport: true + # Non-apt: today this hard-fails at install.sh:2034. Expected; forces the + # decision on whether dnf/pacman/zypper get supported. + - label: fedora41 + image: fedora:41 + runner: ubuntu-latest + experimental: true + overlay: true + + steps: + - name: Describe the container's starting state + run: | + for t in curl wget git gcc cc cmake make python3 sudo; do + printf '%-8s %s\n' "$t" "$(command -v $t 2>/dev/null || echo ABSENT)" + done | tee /tmp/container-baseline.txt + + # The advertised `curl | sh` cannot start on an image without curl, so the + # transport is provisioned apart from the installer's own dependencies. + # Everything else stays absent. + - name: Provision only the bootstrap transport + run: | + # tar and gzip ride along on the overlay legs: with no actions/checkout here + # (it needs git) the only way in for this ref's source is an archive over the + # same transport. Neither is a compiler, git or cmake, so the premise holds; + # both are usually in the base image already, naming them makes it certain. + # + # One transport, never both: the wget-only row tests install.sh's wget branch, + # which is unreachable while curl is on the box. + if [ "${{ matrix.wget_only }}" = "true" ]; then + pkgs="ca-certificates wget" + else + pkgs="ca-certificates curl" + fi + if [ "${{ matrix.overlay }}" = "true" ]; then pkgs="$pkgs tar gzip"; fi + if command -v apt-get >/dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq --no-install-recommends $pkgs + elif command -v dnf >/dev/null 2>&1; then + dnf install -y -q $pkgs + fi + + # No actions/checkout on purpose: it needs git, and a container with git + # preinstalled is not the clean machine under test. Fetch over the transport + # above, INSTALLER included, so these legs validate a fix and not just the + # published script. + - name: Fetch installer + assert script for this ref + run: | + mkdir -p logs .github/scripts + raw="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${GITHUB_SHA}" + # Whichever transport this row provisioned: the wget-only leg has no curl, on + # purpose, and this is the one download in the job that cannot go through + # install.sh's own helper. Same preference order as it uses. + dl() { + if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2" + else wget -q -O "$2" "$1"; fi + } + dl "$raw/.github/scripts/clean-machine-assert.sh" .github/scripts/clean-machine-assert.sh + # Empty on pull_request/push, so only an explicit dispatch tests unsloth.ai. + if [ "${{ inputs.installer_source }}" = "published" ]; then + dl https://unsloth.ai/install.sh install.sh + echo "installer: published (unsloth.ai)" + else + dl "$raw/install.sh" install.sh + echo "installer: this ref (${GITHUB_SHA})" + fi + wc -l install.sh + + # The overlay needs a source tree and these legs deliberately have no + # actions/checkout. codeload serves the same commit as a tarball over plain HTTPS, + # so this ref's Python gets in without a git client. + - name: Fetch this ref's source tree for the overlay + if: matrix.overlay && inputs.installer_source != 'published' + run: | + set -e + mkdir -p ci-source + src="https://codeload.github.com/${GITHUB_REPOSITORY}/tar.gz/${GITHUB_SHA}" + # See the step above: the wget-only leg has to fetch with wget. + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$src" | tar -xz -C ci-source --strip-components=1 + else + wget -q -O - "$src" | tar -xz -C ci-source --strip-components=1 + fi + [ -f ci-source/pyproject.toml ] || { echo "::error::source tarball for ${GITHUB_SHA} unpacked without a pyproject.toml"; ls -la ci-source; exit 1; } + echo "overlay source: $(pwd)/ci-source" + + # curl is what fetched install.sh above, so the no-transport leg cannot simply + # never install it. Take it away again afterwards: from the installer's point of + # view the machine can download nothing, which is the case under test. + - name: Take the transport away again + if: matrix.no_transport + run: | + apt-get remove -y -qq curl >/dev/null + for t in curl wget; do + if command -v "$t" >/dev/null 2>&1; then + echo "::error::$t is still resolvable, so this leg is not the no-transport case" + exit 1 + fi + done + echo "no curl and no wget: the installer has no transport" + + - name: Create an unprivileged user + if: matrix.nonroot + run: | + useradd -m tester + # Switching user without a login shell keeps the caller's environment, so the + # workflow-wide UNSLOTH_STUDIO_HOME follows tester in, and install.sh validates + # that override in _resolve_studio_destinations (536-591), long before the + # elevation gate (829-905). Without a writable target these legs die on "cannot + # be created" rather than on anything they are asking about. + mkdir -p "$UNSLOTH_STUDIO_HOME" + chown -R tester logs install.sh "$UNSLOTH_STUDIO_HOME" + # The editable overlay writes .egg-info next to the pyproject.toml, so the + # source tree has to belong to tester too or the overlay fails on permissions + # rather than on anything this leg asks about. + if [ -d ci-source ]; then chown -R tester ci-source; fi + + # Not calling sudo is not the same as not having it: a leg that merely avoided the + # call would pass on an image where elevation was available all along, and the + # whole claim of these two rows is that there is none to be had. + - name: Prove the unprivileged user genuinely cannot elevate + if: matrix.nonroot + run: | + uid="$(su tester -c 'id -u')" + echo "tester uid: $uid" + if [ "$uid" = "0" ]; then + echo "::error::tester resolved to uid 0, so this leg is not unprivileged" + exit 1 + fi + # Absent from disk, not merely off PATH: install.sh probes with `command -v` + # (830), so a binary tester could not reach would still be a lie about the + # image. + for p in /usr/bin/sudo /bin/sudo /usr/local/bin/sudo /usr/sbin/sudo /sbin/sudo; do + if [ -e "$p" ]; then + echo "::error::$p exists, so this image is not sudo-free" + exit 1 + fi + done + if su tester -c 'command -v sudo' >/dev/null 2>&1; then + echo "::error::sudo resolves for tester; the no-elevation premise does not hold" + exit 1 + fi + # The capability, not just the tool: the escalation install.sh would attempt + # writes the dpkg database, so an unwritable one is what makes `apt-get + # install` impossible for tester. + if su tester -c 'test -w /var/lib/dpkg/status'; then + echo "::error::tester can write the dpkg database, so it is effectively root" + exit 1 + fi + echo "tester is unprivileged, has no sudo on disk, and cannot write dpkg state" + + # The mirror of the sudo proof above, for the other premise this row makes. Not + # calling curl is not the same as not having it: every transport site in install.sh + # probes with `command -v curl` and prefers it (731, 1022, 2078, 3064), so a leg + # that merely avoided the call would go on testing the curl branch and report the + # wget one green. + - name: Prove wget is the only transport + if: matrix.wget_only + run: | + # On disk, not merely off PATH, for the same reason the sudo check is: + # `command -v` is what install.sh asks, and a binary tester could not reach + # would still be a lie about the image. + for p in /usr/bin/curl /bin/curl /usr/local/bin/curl /usr/sbin/curl /sbin/curl /snap/bin/curl; do + if [ -e "$p" ]; then + echo "::error::$p exists, so this leg is not wget-only" + exit 1 + fi + done + for u in root tester; do + if su "$u" -c 'command -v curl' >/dev/null 2>&1; then + echo "::error::curl resolves for $u; the wget-only premise does not hold" + exit 1 + fi + done + # The package too, so a dependency that quietly pulled the binary back in is + # caught rather than silently reinstating the curl branch. + if dpkg-query -W -f='${Status}' curl 2>/dev/null | grep -q 'install ok installed'; then + echo "::error::the curl package is installed; the wget-only premise does not hold" + exit 1 + fi + # And tester really has the other one, or the row is the no-transport case + # wearing a different label. + su tester -c 'command -v wget' >/dev/null 2>&1 || { + echo "::error::tester cannot reach wget, so this leg has no transport at all" + exit 1 + } + echo "wget only: $(su tester -c 'wget --version' | head -1)" + + - name: Install (root) + id: install_root + if: ${{ !matrix.nonroot }} + run: | + set -o pipefail + # Resolved here, not in `env:`, so it tracks the step's real working directory: + # a container job remaps the workspace, and this need not depend on + # github.workspace. + if [ -d ci-source ]; then + export UNSLOTH_CI_SOURCE_OVERLAY="$PWD/ci-source" + echo "overlaying this ref's source from $UNSLOTH_CI_SOURCE_OVERLAY" + fi + rc=0 + # Piped: the advertised command, and the shape that turns an early exit into + # curl:(56). + cat install.sh | sh 2>&1 | tee logs/install.log || rc=$? + echo "installer exit code: $rc" + exit "$rc" + + # The no-elevation case the workflow was missing: everything absent AND no way to + # become root, but the transport the documented one-liner needs is there. Gating, + # and asserted end to end by the same steps the root legs use. + - name: Install (unprivileged, no sudo) + id: install_nonroot + if: ${{ matrix.nonroot && !matrix.no_transport }} + run: | + set -o pipefail + # su without a login shell keeps the environment, so this reaches tester. + if [ -d ci-source ]; then + export UNSLOTH_CI_SOURCE_OVERLAY="$PWD/ci-source" + echo "overlaying this ref's source from $UNSLOTH_CI_SOURCE_OVERLAY" + fi + rc=0 + # Piped, like the root legs: the advertised command, and the shape that turns + # an early exit into curl:(56). + su tester -c 'cat install.sh | sh' 2>&1 | tee logs/install.log || rc=$? + echo "installer exit code: $rc" + exit "$rc" + + - name: Install (unprivileged and no transport, expected to fail cleanly) + id: install_notransport + if: matrix.no_transport + continue-on-error: true + run: | + set -o pipefail + rc=0 + su tester -c 'cat install.sh | sh' 2>&1 | tee logs/install.log || rc=$? + echo "installer exit code: $rc" + exit "$rc" + + # KNOWN OUTCOME PIN. This row is required, and continue-on-error on the step above + # would otherwise tolerate a bootstrap outage or an unrelated early exit exactly + # like the intended diagnostic, so every branch here but the pinned outcome exits 1. + - name: Assert the no-transport outcome is the elevation gate + if: always() && matrix.no_transport && steps.install_notransport.outcome != 'skipped' + run: | + if [ "${{ steps.install_notransport.outcome }}" = "success" ]; then + echo "::error::the installer completed with no transport and no way to elevate; that outcome is new, so this pin is stale" + exit 1 + fi + [ -f logs/install.log ] || { echo "::error::the no-transport leg produced no install log"; exit 1; } + tail -40 logs/install.log + # Both, so a failure anywhere else is still red: it must be the transport that + # was missing, and the no-sudo branch of _smart_apt_install (install.sh:899-903) + # that stopped it, not a prompt, a dpkg lock or a network error. + if ! grep -q "missing: curl" logs/install.log; then + echo "::error::the installer never reported the transport as missing; it did not reach the elevation gate" + exit 1 + fi + if ! grep -q "sudo is not available on this system" logs/install.log; then + echo "::error::the installer did not stop at the no-sudo branch of the apt helper; this is a new failure" + exit 1 + fi + # Actionable, not a bare `curl: (56)`: the message has to say what to run. + if ! grep -q "apt-get install -y curl" logs/install.log; then + echo "::error::the elevation gate did not print the command a user should run" + exit 1 + fi + echo "::notice::known outcome: no transport and no way to elevate, refused with an actionable message" + + # experimental, so without this a bootstrap outage or an unrelated early exit is + # tolerated like the intended diagnostic. + - name: Assert the Fedora outcome is a known one + if: always() && matrix.label == 'fedora41' + run: | + if [ "${{ steps.install_root.outcome }}" = "success" ]; then + echo "::warning::fedora install succeeded -- non-apt support may now exist; retire this leg" + exit 0 + fi + [ -f logs/install.log ] || { echo "::error::fedora leg produced no install log"; exit 1; } + tail -40 logs/install.log + # install.sh comes from this ref, so which of the two accepted outcomes applies + # depends on which dependency gate this ref carries. + if grep -q "using prebuilt llama.cpp (missing:" logs/install.log; then + # The gate no longer hard-stops on a non-apt distro: it warns that the + # optional build tools are absent and carries on, and reaching that warning + # is what proves the Linux gate did not stop the install. Past it, the + # accepted failure used to be release lag: install.sh came from this ref but + # unsloth from PyPI, and the released install_python_stack.py has no "skip + # the triton kernels when git is missing" guard, so it fetched the git+https + # triton_kernels requirement with no git. The overlay makes that guard this + # ref's own code, so the triton failure must NOT come back: accepting it + # would be accepting a regression in the guard as release lag. + if grep -q "Installing triton kernels (pip) failed" logs/install.log; then + echo "::error::triton kernels still failed with this ref's install_python_stack.py overlaid, so its no-git skip did not hold" + exit 1 + fi + # Nothing past the dependency warning is acceptable any more: the one + # tolerated failure was the released package lagging this ref, and the + # overlay removes that difference. + echo "::error::fedora got past the dependency warning and still failed, with this ref's Python overlaid; there is no known-good outcome left to accept" + exit 1 + fi + # This ref still hard-exits on a non-apt package manager. Pin that message so a + # bootstrap outage or an unrelated early exit is not tolerated as the intended + # diagnostic. + if ! grep -qiE "Automatic system package installation is supported on apt-based|Fedora/RHEL: sudo dnf install" logs/install.log; then + echo "::error::fedora leg failed neither at the unsupported-package-manager gate nor past the dependency warning" + exit 1 + fi + + # See the macOS job: proves the leg tests what its matrix row claims. + - name: Assert this ref's Python was really put under test + if: matrix.overlay && inputs.installer_source != 'published' && (steps.install_root.outcome == 'success' || steps.install_nonroot.outcome == 'success') + run: | + grep -q "CI: overlaying source checkout" logs/install.log || { + echo "::error::this leg is marked overlay: true but the installer never overlaid the checkout, so it only tested the released package" + exit 1 + } + echo "overlay applied; this leg exercised this ref's Python" + + # nobuild only reads the log, so an installer that exits 0 having done nothing + # satisfies it. Unlike WSL and Windows, these required Linux rows had no check that + # the install produced anything runnable. + - name: Assert the install is actually usable + if: steps.install_root.outcome == 'success' || steps.install_nonroot.outcome == 'success' + run: | + VENV="$UNSLOTH_STUDIO_HOME/unsloth_studio" + [ -x "$VENV/bin/python" ] || { echo "::error::installer exited 0 but left no managed Python at $VENV/bin/python"; ls -la "$UNSLOTH_STUDIO_HOME" || true; exit 1; } + "$VENV/bin/python" -V + [ -x "$VENV/bin/unsloth" ] || { echo "::error::installer exited 0 but left no unsloth CLI at $VENV/bin/unsloth"; exit 1; } + + - name: Assert llama.cpp came from the prebuilt bundle + if: steps.install_root.outcome == 'success' || steps.install_nonroot.outcome == 'success' + run: | + # HONESTY NOTE: the ROOT legs START toolchain-free but do not stay that way. As + # root, _smart_apt_install's first `apt-get install` (install.sh:797-799) + # succeeds before the _SMART_APT_OPTIONAL guard (814-821) can suppress + # anything, so `cmake git build-essential libcurl4-openssl-dev` really are + # installed mid-run: product behaviour on any root Linux install, not a CI + # artefact. What must still hold is that nothing USED them. `nobuild` reads + # Python builds only, and llama.cpp is the one thing that silently falls back + # to a source compile once a compiler is around. The unprivileged leg never + # gets that far -- the optional set cannot escalate -- so there the same marker + # proves the prebuilt path won with no compiler on the machine at all. + for t in cmake git gcc; do + printf '%-6s %s\n' "$t" "$(command -v "$t" 2>/dev/null || echo ABSENT)" + done + # install_llama_prebuilt.py:5629 writes this marker and a source-built tree has + # no such metadata (studio/setup.sh:1517), so its presence is the one + # unambiguous "the prebuilt path won" signal. + META="$UNSLOTH_STUDIO_HOME/llama.cpp/UNSLOTH_PREBUILT_INFO.json" + if [ ! -f "$META" ]; then + echo "::error::llama.cpp carries no prebuilt metadata at $META, so it did not come from the prebuilt bundle; with the compiler installed above, that is the silent source build this workflow exists to rule out" + ls -la "$UNSLOTH_STUDIO_HOME/llama.cpp" 2>/dev/null || true + grep -nE "llama\.cpp|prebuilt" logs/install.log | tail -30 || true + exit 1 + fi + echo "llama.cpp came from the prebuilt bundle:" + head -c 800 "$META"; echo + + # The claim this leg exists to make: a user with no elevation gets a full install + # and the machine is no less clean afterwards. Without it the row proves only that + # SOME install happened, which the root legs already show. + - name: Assert the unprivileged install elevated nothing + if: steps.install_nonroot.outcome == 'success' + run: | + left="" + for t in sudo cmake git gcc; do + p="$(command -v "$t" 2>/dev/null || echo ABSENT)" + printf '%-6s %s\n' "$t" "$p" + [ "$p" = ABSENT ] || left="$left $t" + done + if [ -n "$left" ]; then + echo "::error::the unprivileged install put system packages on the machine:$left, so something escalated" + exit 1 + fi + # And it took the no-toolchain path knowingly rather than by accident. + if ! grep -q "using prebuilt llama.cpp (missing:" logs/install.log; then + echo "::error::the installer never reported the optional build tools as missing; it did not take the no-toolchain path" + grep -n "deps" logs/install.log | tail -20 || true + exit 1 + fi + # #7547 made the optional set stop escalating. If either prompt comes back, an + # unprivileged user is blocked on tools nothing here uses. + if grep -qE "We require sudo elevated permissions|No terminal to confirm on" logs/install.log; then + echo "::error::the installer tried to elevate for the optional build tools; #7547's no-escalation guard has regressed" + exit 1 + fi + + # Absent at the start is not absent throughout, and only the whole-run claim makes + # the leg mean anything: every download the installer just did, the uv bootstrap + # included (install.sh:2232), had to go through wget, and it did only if curl was + # never there to be preferred. + - name: Re-prove curl never appeared, and that wget carried the install + if: matrix.wget_only && steps.install_nonroot.outcome == 'success' + run: | + for p in /usr/bin/curl /bin/curl /usr/local/bin/curl /usr/sbin/curl /sbin/curl /snap/bin/curl; do + if [ -e "$p" ]; then + echo "::error::$p appeared during the install, so the run did not stay wget-only" + exit 1 + fi + done + if command -v curl >/dev/null 2>&1; then + echo "::error::curl resolves after the install, so the run did not stay wget-only" + exit 1 + fi + # _check_linux_deps (install.sh:2077-2079) calls the transport missing only when + # curl AND wget are both gone, and the elevation gate below it is what the + # notransport row pins. Reaching it here would mean wget was not recognised as a + # transport at all. + if grep -q "missing: curl" logs/install.log; then + echo "::error::install.sh reported the transport as missing on a box that has wget, so it does not accept wget as one" + exit 1 + fi + echo "curl absent before and after; every download went through wget" + + - name: Assert no source build + if: always() + run: | + if [ -f .github/scripts/clean-machine-assert.sh ]; then + INSTALL_LOG=logs/install.log bash .github/scripts/clean-machine-assert.sh nobuild + else + echo "::warning::assert script unavailable (fetch step did not run)" + fi + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: clean-linux-${{ matrix.label }} + path: | + logs/ + /tmp/container-baseline.txt + retention-days: 7 + if-no-files-found: warn + + # ── WSL ─────────────────────────────────────────────────────────────────── + # install.sh carries ~126 lines of WSL-specific logic (the `linux|wsl` dependency + # branch, UNSLOTH_WSL_REROUTED, the Strix Halo reroute to 24.04) that had never run in + # CI: tests/sh/test_strixhalo_wsl_reroute.sh mocks the environment, which cannot catch + # anything about a real WSL. No third-party action either -- the official Ubuntu rootfs + # plus `wsl --import` is deterministic and checksum-verifiable. + # + # Gating, deliberately: it is the only job that runs the real WSL branch and the only + # one that can catch a piped install being truncated (WSL shells out to Windows interop + # mid-script, and interop relays the stdin it inherited). No flake to absorb, and #7548 + # is in main, so this gates unconditionally. + wsl: + name: wsl ubuntu-24.04 + runs-on: windows-latest + timeout-minutes: 50 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Import a fresh Ubuntu 24.04 WSL distro + shell: pwsh + run: | + # WSL2 is present on windows-2022+ runner images; only a distro is missing. + wsl --set-default-version 2 + $url = 'https://cloud-images.ubuntu.com/wsl/releases/24.04/20240423/ubuntu-noble-wsl-amd64-24.04lts.rootfs.tar.gz' + $expected = '2a790896740b14d637dbdc583cce1ba081ac53b9e9cdb46dc09a2f73abbd9934' + New-Item -ItemType Directory -Force -Path wsl-dist, logs | Out-Null + Invoke-WebRequest -Uri $url -OutFile wsl-dist/rootfs.tar.gz -UseBasicParsing -TimeoutSec 900 + $actual = (Get-FileHash wsl-dist/rootfs.tar.gz -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected) { + Write-Host "::error::rootfs checksum mismatch: got $actual" + exit 1 + } + wsl --import unsloth-ci "$PWD/wsl-dist/instance" "$PWD/wsl-dist/rootfs.tar.gz" --version 2 + wsl -d unsloth-ci -- uname -a + # A freshly imported rootfs is genuinely bare: no curl, git or compiler. The + # clean machine, not a simulation. + wsl -d unsloth-ci -- sh -c 'for t in curl wget git gcc cmake python3 sudo; do printf "%-8s %s\n" "$t" "$(command -v $t || echo ABSENT)"; done' + + - name: Install inside WSL, piped exactly as documented + shell: pwsh + run: | + # Only ca-certificates + curl: the advertised one-liner cannot start without a + # transport. Everything else must come from the installer. + wsl -d unsloth-ci -u root -- sh -c 'apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl' 2>&1 | Tee-Object -FilePath logs/wsl-bootstrap.log + # A dispatch selecting `published` asks whether unsloth.ai's script works, and + # running the checked-out one answered a different question under the same + # name. Empty on pull_request/push, so automatic runs stay on this ref. + if ('${{ inputs.installer_source }}' -eq 'published') { + Write-Host 'installer: published (unsloth.ai)' + wsl -d unsloth-ci -u root -- sh -c 'curl -fsSL https://unsloth.ai/install.sh -o /root/install.sh' + if ($LASTEXITCODE -ne 0) { + Write-Host '::error::could not fetch the published installer inside WSL' + exit 1 + } + } else { + # Copy the script in rather than reach across /mnt/c: a DrvFs path brings + # Windows permissions and CRLF risk, neither of which a real WSL user has. + $wslPath = (wsl -d unsloth-ci -- wslpath -a "$($env:GITHUB_WORKSPACE -replace '\\','/')/install.sh").Trim() + Write-Host "installer source in WSL: $wslPath" + wsl -d unsloth-ci -u root -- cp "$wslPath" /root/install.sh + } + # Piped, same shape as `curl ... | sh`, so an early exit still exposes the + # broken pipe, but on the script this dispatch selected. + wsl -d unsloth-ci -u root -- sh -c 'cd /root && cat install.sh | sh' 2>&1 | Tee-Object -FilePath logs/wsl-install.log + $installRc = $LASTEXITCODE + Write-Host "installer exit: $installRc" + + if (-not (Test-Path logs/wsl-install.log)) { + Write-Host '::error::the WSL install produced no log' + exit 1 + } + # The pipe-integrity check. Interop (_maybe_reroute_strixhalo_to_2404 -> + # powershell.exe, wsl.exe) relays the stdin it inherited, so before #7548 it + # drank the rest of the piped script and sh died on a half-read line. #7548's + # _unsloth_main wrapper forces sh to parse the file in full first; a truncation + # here means that regressed. + if (Select-String -Path logs/wsl-install.log ` + -Pattern 'Syntax error: Unterminated quoted string' -Quiet) { + Write-Host '::error::the piped install was truncated again; install.sh is no longer parsed in full before it runs' + exit 1 + } + # Printing the code discarded it, and the next step's CLI check cannot + # compensate: install.sh links the `unsloth` shim (4174-4182) BEFORE it reports + # a failing studio/setup.sh (4219-4230), so a late setup failure leaves a shim + # whose --version succeeds. + if ($installRc -ne 0) { + Write-Host "::error::WSL installer exited $installRc" + exit $installRc + } + + - name: Did it detect WSL, and did it end up usable? + if: always() + shell: pwsh + run: | + # The platform line proves the wsl branch was taken rather than plain linux. + Select-String -Path logs/wsl-install.log -Pattern 'platform|\[TAURI:DIAG\]|wsl' -ErrorAction SilentlyContinue | + Select-Object -First 10 + # Printing could not fail, and that alternation also matches `platform linux`, + # so a detection regression would pass as a plain-Linux install. `step` writes + # the label in reverse video, so strip ANSI or an anchored match never hits. + $esc = [char]27 + $platformLines = @( + Get-Content logs/wsl-install.log -ErrorAction SilentlyContinue | + ForEach-Object { $_ -replace "$esc\[[0-9;]*[A-Za-z]", '' } | + Where-Object { $_ -match '^\s*platform\s+\S' } + ) + $platformLines | ForEach-Object { Write-Host "platform line: $_" } + if (-not ($platformLines | Where-Object { $_ -match '^\s*platform\s+wsl\s*$' })) { + Write-Host '::error::installer never reported ''platform wsl''; the WSL branch was not exercised' + exit 1 + } + # No `|| echo`: substituting a message for the missing CLI made the inner shell, + # this step and the job all succeed on an install that produced nothing. + $verify = wsl -d unsloth-ci -u root -- sh -c 'set -e; test -x "$HOME/.unsloth/studio/unsloth_studio/bin/unsloth"; "$HOME/.unsloth/studio/unsloth_studio/bin/unsloth" --version' 2>&1 + $verifyRc = $LASTEXITCODE + $verify | Tee-Object -FilePath logs/wsl-verify.log + if ($verifyRc -ne 0) { + Write-Host '::error::WSL install left no usable unsloth CLI' + exit 1 + } + + - name: Tear the distro down + if: always() + shell: pwsh + run: wsl --unregister unsloth-ci 2>&1 | Out-Null; exit 0 + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: clean-wsl-ubuntu2404 + path: logs/ + retention-days: 7 + if-no-files-found: warn + + # ── Windows ─────────────────────────────────────────────────────────────── + windows: + name: win ${{ matrix.os }} / winget=${{ matrix.winget }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + winget: 'visible' + experimental: false + overlay: true + # The no-winget path (LTSC / Server / managed corporate machines) falls back to + # python.org + astral.sh and is untested today. It is also where Ensure-VCRedist + # silently does not run, leaving torch unable to load, hence the explicit + # `import torch` assert below. + # + # It used to stop at studio/setup.ps1's unconditional "Git is required but could + # not be installed automatically" gate -- no winget meant no way to fetch git -- + # and was a pinned known failure until #7549 relaxed that gate to the --local + # and llama.cpp source paths that actually use git (setup.ps1:1750-1759). The + # row now installs end to end and gates like any other; the assert below proves + # it took the relaxed branch rather than passing on git leaking back onto PATH. + - os: windows-latest + winget: 'masked' + experimental: false + overlay: true + # Windows on ARM gets a native ARM64 CPython, and torchaudio has never published + # a win_arm64 wheel at any version (nor have pyarrow and hf-transfer, which + # datasets pulls in), so the PyTorch step could not resolve and install.ps1 + # stopped at "Failed to install PyTorch". Pinned until #7549, which makes the + # installer prefer an x64 interpreter on an ARM64 host and bootstrap one when + # only ARM64 is installed (install.ps1:1160-1253, 1335-1353); x64 wheels run + # fine emulated. The row now gates, and the assert below checks the outcome that + # fix has to produce rather than the log line announcing it. + - os: windows-11-arm + winget: 'visible' + experimental: false + overlay: true + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # No actions/setup-python here either: install.ps1 must bootstrap Python. + + - name: Simulate a clean machine + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path logs | Out-Null + # Drop preinstalled Python, git, CMake, VS/LLVM and the WindowsApps aliases from + # PATH. A full Visual Studio uninstall is not realistic in CI (registry + + # vswhere discovery, slow, may need a reboot), so PATH and env scrubbing is the + # honest approximation, recorded as such. + $drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\', + 'CMake', 'Microsoft Visual Studio', 'BuildTools', 'LLVM', + 'MSYS', 'mingw', 'Strawberry') + # winget is an app-execution alias in ...\Local\Microsoft\WindowsApps, so the + # blanket drop removed it on EVERY leg and winget=visible silently ran the same + # fallback as winget=masked. Resolve it before the scrub and hand it back via a + # shim, so the visible leg gets winget without the Store's python.exe alias. + # windows-11-arm has no winget on the hosted image + # (actions/runner-images#14083), so only windows-latest can carry it. + $wantWinget = ('${{ matrix.winget }}' -ne 'masked') -and ('${{ matrix.os }}' -eq 'windows-latest') + $wingetCmd = Get-Command winget -ErrorAction SilentlyContinue + $scrub = { + param($entries) + $out = $entries | Where-Object { + $p = $_; $p -and -not ($drop | Where-Object { $p -like "*$_*" }) + } + if ('${{ matrix.winget }}' -eq 'masked') { + $out = $out | Where-Object { $_ -notlike '*WinGet*' -and $_ -notlike '*Microsoft\WindowsApps*' } + } + ,@($out) + } + $kept = & $scrub ($env:PATH -split ';') + if ($wantWinget) { + if (-not $wingetCmd) { + Write-Host '::error::winget was not on PATH before scrubbing; this leg cannot test the winget path' + exit 1 + } + $shim = Join-Path $env:RUNNER_TEMP 'winget-shim' + New-Item -ItemType Directory -Force -Path $shim | Out-Null + Set-Content -LiteralPath (Join-Path $shim 'winget.cmd') -Encoding ascii ` + -Value "@`"$($wingetCmd.Source)`" %*" + $kept = @($shim) + $kept + } + # Take the toolcache Python off disk, not just off PATH: py.exe lives in + # C:\Windows (which must stay) and uv does its own interpreter discovery, so + # both reach the toolcache whatever PATH says. That is how a leg printing + # `python ABSENT` still installed the runner's 3.13.14. + foreach ($tc in @("$env:AGENT_TOOLSDIRECTORY\Python", 'C:\hostedtoolcache\windows\Python')) { + if ($tc -and (Test-Path $tc)) { + try { Rename-Item -LiteralPath $tc -NewName 'Python.masked' -ErrorAction Stop + Write-Host "masked toolcache python: $tc" } + catch { Write-Host "::error::could not mask $tc ($($_.Exception.Message)); the leg would not be clean"; exit 1 } + } + } + $newPath = ($kept -join ';') + "PATH=$newPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + # install.ps1's Refresh-SessionPath (318-337, called at 1246/1278/1295/1360/ + # 1369/2797) merges the Machine and User registry PATHs back into $env:Path, so + # a process-only scrub lasts until the first bootstrap refresh, after which + # Git/CMake/VS/LLVM are back and the rest of the install is not clean. The + # runner is ephemeral, so rewrite the registry copies too. A merge, not a + # replace, so the shim above keeps resolving. Expand first: SetEnvironmentVariable + # rewrites REG_EXPAND_SZ as REG_SZ (dotnet/runtime#1442). + foreach ($scope in 'Machine','User') { + $raw = [System.Environment]::GetEnvironmentVariable('Path', $scope) + if ([string]::IsNullOrWhiteSpace($raw)) { continue } + $expanded = [System.Environment]::ExpandEnvironmentVariables($raw) -split ';' + try { + [System.Environment]::SetEnvironmentVariable('Path', ((& $scrub $expanded) -join ';'), $scope) + } catch { + Write-Host "::error::could not scrub the $scope PATH ($($_.Exception.Message)); the simulation would not survive Refresh-SessionPath" + exit 1 + } + } + foreach ($v in 'VSINSTALLDIR','VCINSTALLDIR','WindowsSdkDir','INCLUDE','LIB','LIBPATH') { + "$v=" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + } + Write-Host "kept PATH entries: $($kept.Count)" + + - name: Verify the simulation took effect + shell: pwsh + run: | + $leaked = @() + # `py` too: the launcher lives in C:\Windows, which the scrub keeps, and finds + # the toolcache Python the scrub only removed from PATH. + foreach ($t in 'python','py','git','cmake','cl') { + $f = Get-Command $t -ErrorAction SilentlyContinue + Write-Host ("{0,-8} {1}" -f $t, $(if ($f) { $f.Source } else { 'ABSENT' })) + if ($f -and $t -ne 'py') { $leaked += "$t -> $($f.Source)" } + } + # The launcher binary may stay, but an interpreter it can still START is a leak: + # Find-CompatiblePython (install.ps1:1130-1153) probes `py` first, so a version + # registered outside the renamed toolcache dirs gets reused and the Python + # bootstrap never runs. + if (Get-Command py -ErrorAction SilentlyContinue) { + # -0p is the launcher's REGISTRY view, and the mask renames directories + # without rewriting it, so -0p keeps naming paths that no longer exist: + # context for a failure, never evidence. Only a probe that STARTS counts. + Write-Host "py -0p (stale registry entries; masked paths no longer exist on disk):" + & py -0p 2>&1 | ForEach-Object { Write-Host " $_" } + foreach ($v in '-3.11', '-3.12', '-3.13') { + $out = & py $v -c "import sys; print(sys.executable)" 2>&1 + $rc = $LASTEXITCODE + # Print every probe: when this next fails it must say why. + Write-Host ("py {0} -> exit {1}: {2}" -f $v, $rc, (($out | Out-String).Trim() -replace '\r?\n', ' / ')) + if ($rc -eq 0) { $leaked += "py $v -> $out" } + } + # A FAILING probe is the outcome we want, but it leaves $LASTEXITCODE + # non-zero, cmdlets never reset it, and the runner appends `exit + # $LASTEXITCODE` to every pwsh step (actions/runner#351) -- so all three legs + # exited 1, silently, on machines that were in fact clean. + $global:LASTEXITCODE = 0 + } + # Printing alone could not fail: run 30365014702 logged `python ABSENT` then + # `Python 3.13 already installed` / `Using CPython ... C:\hostedtoolcache\...`. + if ($leaked) { + Write-Host "::error::developer tooling survived the scrub: $($leaked -join '; ')" + exit 1 + } + $winget = Get-Command winget -ErrorAction SilentlyContinue + Write-Host ("winget {0}" -f $(if ($winget) { $winget.Source } else { 'ABSENT' })) + if ('${{ matrix.winget }}' -eq 'masked') { + if ($winget) { + Write-Host '::error::winget still resolvable; masking failed' + exit 1 + } + } elseif ('${{ matrix.os }}' -eq 'windows-latest' -and -not $winget) { + # Or the visible leg quietly degrades into a second masked leg. + Write-Host '::error::winget is not resolvable on the visible leg; the winget bootstrap is not under test' + exit 1 + } + foreach ($scope in 'Machine','User') { + Write-Host ("{0} PATH after scrub: {1}" -f $scope, [System.Environment]::GetEnvironmentVariable('Path', $scope)) + } + # Every failure above exits 1 explicitly, so reaching here means clean. Say so + # rather than let the runner's appended `exit $LASTEXITCODE` decide. + exit 0 + + - name: Install + id: install + shell: pwsh + env: + # Empty, and therefore ignored by install.ps1, on the non-overlay legs. + UNSLOTH_CI_SOURCE_OVERLAY: ${{ matrix.overlay && inputs.installer_source != 'published' && github.workspace || '' }} + run: | + $ErrorActionPreference = 'Continue' + # Windows ships its own published script (install.ps1:3), so `published` means + # something here too: running the checked-out one regardless made a dispatch + # asking about unsloth.ai report on this ref. Empty on pull_request/push, so + # automatic runs stay on this ref. + $script = './install.ps1' + if ('${{ inputs.installer_source }}' -eq 'published') { + Invoke-WebRequest -Uri https://unsloth.ai/install.ps1 ` + -OutFile published-install.ps1 -UseBasicParsing -TimeoutSec 300 + $script = './published-install.ps1' + Write-Host 'installer: published (unsloth.ai)' + } else { + Write-Host "installer: this ref ($env:GITHUB_SHA)" + } + # No -SkipTorch: install.ps1's parser matches `--no-torch` only (112-142), so the + # token was silently dropped and every leg installed torch anyway. The assert + # below needs torch, so get it on purpose. Under powershell.exe, not this pwsh 7 + # step: a clean Windows box ships Windows PowerShell 5.1 only, and the desktop + # launches it the same way (install.rs:325-339). + & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File $script *>&1 | Tee-Object -FilePath logs/install.log + $rc = $LASTEXITCODE + Write-Host "installer exit code: $rc" + exit $rc + + # Windows asserted nothing about the install ITSELF: nobuild and the toolchain check + # only read the log, so an installer that exited 0 having produced nothing satisfied + # both. The Linux legs have had this since they stopped being pinned; these rows + # needed it more, two of them being only just off a pin. + - name: Assert the install is actually usable + shell: pwsh + run: | + $venv = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio' + $py = Join-Path $venv 'Scripts\python.exe' + if (-not (Test-Path -LiteralPath $py)) { + Write-Host "::error::installer exited 0 but left no managed Python at $py" + Get-ChildItem -LiteralPath $env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue | + Format-Table | Out-String | Write-Host + exit 1 + } + & $py -V + $cli = Join-Path $venv 'Scripts\unsloth.exe' + if (-not (Test-Path -LiteralPath $cli)) { + Write-Host "::error::installer exited 0 but left no unsloth CLI at $cli" + exit 1 + } + # Present is not runnable: the console script imports the whole command tree, so + # a missing dependency or an unimportable extension surfaces here and nowhere + # else. --version is the one subcommand-free path. + & $cli --version + if ($LASTEXITCODE -ne 0) { + Write-Host '::error::the unsloth CLI is on disk but does not run' + exit 1 + } + + # What #7549 has to produce on this host, checked as an outcome rather than the log + # line announcing it. torchaudio, pyarrow and hf-transfer publish no win_arm64 wheel + # at any version, so a native ARM64 interpreter cannot resolve the stack; the + # installer's answer is to prefer, and if necessary bootstrap, an x64 CPython and + # let it run emulated. Asked of the interpreter through sysconfig, not inferred from + # PROCESSOR_ARCHITECTURE, which describes the shell rather than the venv. + - name: Assert the ARM64 host installed against an x64 interpreter + if: matrix.os == 'windows-11-arm' + shell: pwsh + run: | + $venvPy = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\python.exe' + $tag = (& $venvPy -c "import sysconfig; print(sysconfig.get_platform())" 2>&1 | Out-String).Trim() + $global:LASTEXITCODE = 0 + Write-Host "venv interpreter platform: $tag" + if ($tag -ne 'win-amd64') { + Write-Host "::error::the venv was built from a '$tag' interpreter, so the x64 preference on ARM64 hosts has regressed and the missing win_arm64 wheels are back" + exit 1 + } + # The package that has never shipped a win_arm64 wheel, so its presence proves + # the emulated x64 stack really resolved rather than being skipped. Metadata, + # not an import: this asserts resolution, the torch assert below does the import. + & $venvPy -c "from importlib.metadata import version; print('torchaudio', version('torchaudio'))" + if ($LASTEXITCODE -ne 0) { + Write-Host '::error::torchaudio is not installed, so the x64 interpreter did not buy the wheels it was chosen for' + exit 1 + } + + # The other half of what #7549 has to produce. This row is the only place the relaxed + # git gate matters: winget is masked, so there is no way to fetch git at all, and + # setup.ps1 used to refuse to continue without it. Assert the relaxed branch was + # taken, so the row cannot go green on git leaking back onto PATH with the gate + # never reached. + - name: Assert the no-winget path installed without git + if: matrix.winget == 'masked' + shell: pwsh + run: | + $log = Get-Content logs/install.log -Raw + if ($log -match 'Git is required but could not be installed automatically') { + Write-Host '::error::setup.ps1 stopped at the unconditional git gate; #7549 relaxed it to --local and source-build installs, so that has regressed' + exit 1 + } + if (-not ($log -match 'Git not found -- attempting install via winget')) { + Write-Host '::error::setup.ps1 found git on this machine, so the scrub leaked it back and this row never exercised the no-git path' + exit 1 + } + # setup.ps1:1757-1758, the non-fatal branch: git absent, nothing on the consumer + # path needs it, install continues. + if (-not ($log -match 'so git is not needed')) { + Write-Host '::error::setup.ps1 never reported git as absent-but-not-required; the relaxed gate did not run' + exit 1 + } + Write-Host 'no winget, no git, and the install completed anyway' + + # See the macOS job: proves the leg tests what its matrix row claims. + - name: Assert this ref's Python was really put under test + if: matrix.overlay && inputs.installer_source != 'published' && steps.install.outcome == 'success' + shell: pwsh + run: | + if (-not (Select-String -Path logs/install.log -Pattern 'CI: overlaying source checkout' -SimpleMatch -Quiet)) { + Write-Host '::error::this leg is marked overlay: true but the installer never overlaid the checkout, so it only tested the released package' + exit 1 + } + Write-Host "overlay applied; this leg exercised this ref's Python" + + - name: Assert the install added no compiler toolchain + if: always() && steps.install.outcome != 'skipped' + shell: pwsh + run: | + # Windows checked nothing after the install, so setup.ps1 committing to a + # llama.cpp SOURCE build would winget-install CMake (setup.ps1:816-822) and VS + # Build Tools (845-857) and the leg still went green. Git is out of scope on + # purpose: bootstrapping it through winget (1658-1661) is the consumer path the + # visible leg exercises. The VC++ runtime is a runtime, not a toolchain. + $bad = @() + if (-not (Test-Path logs/install.log)) { + Write-Host '::error::no install log, so nothing proves the install stayed toolchain-free' + exit 1 + } + # The announcements inside Ensure-BuildToolsForLlamaSourceBuild, which runs only + # for a committed source build. Matched instead of the package ids because + # setup.ps1 PRINTS `winget install ...BuildTools` as manual advice when winget is + # missing, and advice is not an install. + foreach ($m in 'CMake not found -- installing via winget', + 'Visual Studio Build Tools not found -- installing via winget') { + if (Select-String -Path logs/install.log -Pattern $m -SimpleMatch -Quiet) { + $bad += "install log reports: $m" + } + } + # winget puts what it installs on the MACHINE PATH, which this step's own process + # PATH (scrubbed, from GITHUB_ENV) never sees, so read the registry copies back + # rather than ask Get-Command. The scrub removed every entry matching these, so + # a match here means the install put one back. + foreach ($scope in 'Machine','User') { + $raw = [System.Environment]::GetEnvironmentVariable('Path', $scope) + if ([string]::IsNullOrWhiteSpace($raw)) { continue } + foreach ($e in ([System.Environment]::ExpandEnvironmentVariables($raw) -split ';')) { + if ($e -match 'CMake|BuildTools|Microsoft Visual Studio|LLVM') { + $bad += "$scope PATH regained $e" + } + } + } + if ($bad) { + Write-Host "::error::the install put a compiler toolchain on this machine: $($bad -join '; ')" + exit 1 + } + Write-Host 'no CMake and no VS Build Tools install; the prebuilt contract held' + + - name: Assert no source build + if: always() && steps.install.outcome != 'skipped' + shell: pwsh + run: | + # The step above only catches a NEW CMake or VS Build Tools install. The image's + # Visual Studio survives a PATH scrub: setup.ps1's Find-VsBuildTools (763-800) + # reaches it through vswhere and a Program Files scan, and the visible leg logs + # `vs Visual Studio 18 2026 (vswhere)` on the same machine whose pre-flight + # printed `cl ABSENT`. So a dependency that lost its Windows wheel would compile + # against that MSVC and the leg would stay green while macOS and Linux caught + # it. uv really does build sdists here (openai-whisper, antlr4-python3-runtime, + # randomname, argbind), so this is the live path. + & "$env:GITHUB_WORKSPACE/.github/scripts/assert-nobuild.ps1" -LogPath logs/install.log + + - name: Assert torch loads, and record what that does and does not prove + if: steps.install.outcome == 'success' + shell: pwsh + run: | + # HONESTY NOTE: the image ships the VC++ 2015-2022 runtime in System32 and cannot + # lose it without breaking the runner, so `import torch` succeeding does NOT + # prove a clean no-winget machine has it -- Test-VCRedistInstalled (setup.ps1:875) + # finds the preinstalled DLL and Ensure-VCRedist (891) short-circuits. Record + # that, then assert what CAN fail. + $sys32 = Join-Path $env:WINDIR 'System32\vcruntime140_1.dll' + Write-Host "preinstalled System32 vcruntime140_1.dll: $(Test-Path $sys32)" + # The managed interpreter, with no fallback to whatever `python` resolves to: the + # usability assert above already hard-fails when it is missing, and a fallback + # would answer this with an interpreter the install did not create. + $py = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\python.exe' + & $py -c "import ctypes.util, sys; print('VCRUNTIME140:', ctypes.util.find_library('vcruntime140'))" + & $py -c "import torch; print('torch', torch.__version__)" + if ($LASTEXITCODE -ne 0) { Write-Host '::error::torch failed to import (VC++ runtime missing?)'; exit 1 } + if ('${{ matrix.winget }}' -eq 'masked') { + # install.ps1:1098, the no-winget branch of the winget check. + $noWinget = 'will require Python + uv to be already installed' + if (-not (Select-String -Path logs/install.log -Pattern $noWinget -SimpleMatch -Quiet)) { + Write-Host '::error::masked leg never reported winget as unavailable; it did not take the no-winget path' + exit 1 + } + } + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: clean-win-${{ matrix.os }}-${{ matrix.winget }} + path: logs/ + retention-days: 7 + if-no-files-found: warn + + # ── Windows, genuinely virgin: the same install inside a Windows container ──── + # The `win` legs above only SIMULATE absence, and two things they structurally cannot + # test are the VC++ 2015-2022 runtime (it ships in the runner image's System32 and + # cannot be removed without breaking the runner) and a Windows with no Microsoft Store + # at all rather than a winget hidden from PATH. A servercore container answers both, so + # this lane lives here: same premise, same path filters, masked next to real. + # + # Constraints, all load-bearing: + # * `container:` is Linux-only on the Actions runner (actions/runner#1402), so docker + # is driven from ordinary `run:` steps and the payload goes in by `docker cp` -- + # actions/checkout inside the container would need git. + # * servercore, not nanoserver: install.ps1 needs Windows PowerShell 5.1, which + # nanoserver does not ship at all. + # * windows-2022, not windows-latest: process isolation needs the host and container + # builds to match, and only the 2022 image pre-caches ltsc2022. windows-latest is + # Server 2025 and caches no Windows images. + windows_container_probe: + name: virgin win container / probe + runs-on: windows-2022 + timeout-minutes: 30 + env: + IMAGE: mcr.microsoft.com/windows/servercore:ltsc2022 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + # Docker is on every windows-2022 image but is not always already running: one spike + # leg died in 21s on npipe:////./pipe/docker_engine, which misreads as "Windows + # containers are unavailable". + - name: Ensure the Docker daemon is running + shell: pwsh + run: ./.github/scripts/ensure-docker-daemon.ps1 + + - name: Docker engine facts + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path logs | Out-Null + docker version + $osType = (docker info --format '{{.OSType}}').Trim() + Write-Host "OSType $osType / Isolation $(docker info --format '{{.Isolation}}')" + if ($osType -ne 'windows') { + Write-Host "::error::docker is serving '$osType' containers, not windows; this lane cannot run here" + exit 1 + } + docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' + + - name: Start the container + shell: pwsh + run: | + # Never refresh a cached image: process isolation needs the container build <= + # the host build, and MCR has shipped a patched image ahead of the host before + # (actions/runner-images#11582 broke Windows containers for ~2 weeks). + if ((docker images --format '{{.Repository}}:{{.Tag}}') -contains $env:IMAGE) { + Write-Host "using the runner's pre-cached $env:IMAGE (no pull)" + } else { + Write-Host "::warning::$env:IMAGE is not pre-cached; pulling (slow, and it may outrun the host build)" + docker pull $env:IMAGE + if ($LASTEXITCODE -ne 0) { Write-Host "::error::could not pull $env:IMAGE"; exit 1 } + } + # A keepalive entrypoint so each assertion can be its own `docker exec`, and so + # its own step with its own exit code. + docker run -d --name virgin $env:IMAGE cmd /c "ping -t localhost >nul" + if ($LASTEXITCODE -ne 0) { Write-Host "::error::could not start a container from $env:IMAGE"; exit 1 } + Write-Host "isolation: $(docker inspect virgin --format '{{.HostConfig.Isolation}}')" + docker exec virgin cmd /c "mkdir C:\ci" + docker cp "$env:GITHUB_WORKSPACE\." virgin:C:\ci + docker exec virgin cmd /c "dir C:\ci\install.ps1" + + - name: Assert the container is genuinely virgin + shell: pwsh + run: | + docker exec virgin powershell.exe -NoLogo -NoProfile -NonInteractive ` + -ExecutionPolicy Bypass -File C:\ci\.github\scripts\virgin-windows-probe.ps1 ` + *>&1 | Tee-Object -FilePath logs/virginity.log + exit $LASTEXITCODE + + - name: Tear down + if: always() + shell: pwsh + run: | + docker rm -f virgin 2>&1 | Out-Null + exit 0 + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: virgin-win-probe + path: logs/ + retention-days: 7 + if-no-files-found: warn + + windows_container_install: + name: virgin win container / overlay=${{ matrix.overlay }} + needs: windows_container_probe + runs-on: windows-2022 + timeout-minutes: 90 + env: + IMAGE: mcr.microsoft.com/windows/servercore:ltsc2022 + strategy: + fail-fast: false + matrix: + include: + # The consumer path: install.ps1 from this ref, unsloth from PyPI. So + # studio/setup.ps1 comes out of the RELEASED wheel, which is why this row and the + # overlay one below do not currently reach the same place: see the pin on the + # Install step. + - overlay: false + # This ref's studio/setup.ps1 and install_python_stack.py, via + # UNSLOTH_CI_SOURCE_OVERLAY (install.ps1:2643). Without it a branch changing + # setup.ps1 gets a green run proving nothing about the change. + - overlay: true + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Ensure the Docker daemon is running + shell: pwsh + run: ./.github/scripts/ensure-docker-daemon.ps1 + + - name: Start the container + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path logs | Out-Null + # See the probe job: use the pre-cached image, never refresh it. + if (-not ((docker images --format '{{.Repository}}:{{.Tag}}') -contains $env:IMAGE)) { + Write-Host "::warning::$env:IMAGE not pre-cached; pulling" + docker pull $env:IMAGE + } + docker run -d --name virgin $env:IMAGE cmd /c "ping -t localhost >nul" + if ($LASTEXITCODE -ne 0) { Write-Host "::error::could not start a container from $env:IMAGE"; exit 1 } + docker exec virgin cmd /c "mkdir C:\ci" + docker cp "$env:GITHUB_WORKSPACE\." virgin:C:\ci + docker exec virgin cmd /c "mkdir C:\ci-out" + + # Re-run here and not only in `probe`: different runner, and an install leg that + # skipped the check would report on an environment it never verified. + - name: Assert the container is genuinely virgin + shell: pwsh + run: | + docker exec virgin powershell.exe -NoLogo -NoProfile -NonInteractive ` + -ExecutionPolicy Bypass -File C:\ci\.github\scripts\virgin-windows-probe.ps1 ` + *>&1 | Tee-Object -FilePath logs/virginity.log + exit $LASTEXITCODE + + # AFTER the virginity assertion, so that assertion still proves what it says. A fresh + # container ships an almost empty trusted-root store while a real desktop fills it + # via automatic root update, so seeding makes this MORE representative. Needed + # because studio/install_node_prebuilt.py downloads Node with bare + # urllib.request.urlopen, reads the empty Windows ROOT store and gets + # CERTIFICATE_VERIFY_FAILED; uv and pip bundle certifi. Reported separately. + - name: Seed the container's trusted root CA store + shell: pwsh + run: | + # -generateSSTFromWU pulls each root from ctldl.windowsupdate.com, which times + # out often enough to be the leg's main flake (staging run 30423072537 died on + # WinHttp 12002 while the sibling row seeded fine). Retry, but never tolerate a + # total failure: without the roots Node's urllib download later fails with + # CERTIFICATE_VERIFY_FAILED. + for ($i = 1; $i -le 3; $i++) { + docker exec virgin cmd /c "certutil -generateSSTFromWU C:\roots.sst && certutil -addstore -f Root C:\roots.sst" ` + *>&1 | Select-Object -Last 15 + if ($LASTEXITCODE -eq 0) { break } + Write-Host "::warning::root CA seeding attempt $i failed; retrying" + Start-Sleep -Seconds 15 + } + if ($LASTEXITCODE -ne 0) { + Write-Host '::error::could not seed the container root CA store after 3 attempts; Python-side HTTPS will fail' + exit 1 + } + + - name: Install into the virgin container + id: install + shell: pwsh + # RELEASE-LAG PIN, overlay=false only. A Server Core container has no Microsoft + # Store and so no winget, ever, and studio/setup.ps1 used to hard-stop on a + # winget-only git gate and reach for winget again for the VC++ runtime. #7549 + # relaxed both and this branch has it, but the released wheel does not: unpacking + # unsloth 2026.7.5 (uploaded 2026-07-23, #7549 landed on the 28th) shows its + # setup.ps1 still carrying the old gate, so the row that deliberately installs + # from PyPI cannot get past it. Release lag, not a product gap; nothing in this + # branch can change it, only the next RELEASE, not a merge. The overlay row runs + # the same install against this ref's setup.ps1 and gates unconditionally, and + # the step below accepts only that exact signature, hard-erroring the moment the + # released wheel catches up. + continue-on-error: ${{ !matrix.overlay }} + run: | + $overlayArg = if ('${{ matrix.overlay }}' -eq 'true') { 'C:\ci' } else { '' } + docker exec virgin powershell.exe -NoLogo -NoProfile -NonInteractive ` + -ExecutionPolicy Bypass -File C:\ci\.github\scripts\virgin-windows-install.ps1 ` + -Overlay "$overlayArg" *>&1 | Tee-Object -FilePath logs/install-outer.log + exit $LASTEXITCODE + + # The overlay row runs this ref's studio/setup.ps1, so it carries #7549 and has to + # install end to end. The in-container harness already asserts the venv interpreter, + # the unsloth CLI, `import torch`, the no-winget path, the overlay marker and + # nobuild, and exits 1 listing every failure -- so the Install step gating is most + # of the assertion. Added here is the part this lane alone can prove. + - name: Assert the virgin container install proved what this lane exists for + if: matrix.overlay + shell: pwsh + run: | + $log = Get-Content logs/install-outer.log -Raw + # A `docker exec` that lost its container also exits 0, so read the harness's own + # verdict rather than trust the exit code alone. + if (-not ($log -match 'VIRGIN WINDOWS CONTAINER INSTALL PASSED')) { + Write-Host '::error::the install step exited 0 but the in-container harness never printed its passing verdict' + exit 1 + } + # The overlay hook is this PR's own feature and gates unconditionally: without it + # this row is indistinguishable from the released-wheel one. + if (-not ($log -match 'CI: overlaying source checkout')) { + Write-Host '::error::the overlay row never overlaid the checkout, so it only tested the released package' + exit 1 + } + # Git: no Store, no winget, no git, and nothing on the consumer path needs it. The + # relaxed gate is the only reason this row gets past setup.ps1 at all. + if ($log -match 'Git is required but could not be installed automatically') { + Write-Host '::error::studio/setup.ps1 stopped at the unconditional git gate; the relax to --local and llama.cpp source-build installs has regressed' + exit 1 + } + if (-not ($log -match 'so git is not needed')) { + Write-Host '::error::setup.ps1 never reported git as absent-but-not-required, so this container was not gitless and the relaxed gate went untested' + exit 1 + } + # VC++: this container is the ONLY environment in the workflow whose System32 does + # not already ship the 2015-2022 runtime (the hosted legs cannot remove it + # without breaking the runner), so it is the only place the direct aka.ms + # download can be proved to run rather than be short-circuited by + # Test-VCRedistInstalled. Both halves: the fallback was taken, and it worked. + if (-not ($log -match 'downloading the runtime directly')) { + Write-Host '::error::Ensure-VCRedist never took the direct-download fallback, so a container with no VC++ runtime and no winget did not exercise it' + exit 1 + } + if ($log -match 'Could not install the VC\+\+ Redistributable automatically') { + Write-Host '::error::the direct VC++ runtime download ran but left the runtime uninstalled' + exit 1 + } + # The harness already ran `import torch` against the managed interpreter, which is + # what needs VCRUNTIME140_1.dll; this announces that the DLL got there rather + # than having been there all along. + Write-Host '::notice::no Store, no winget, no git and no preinstalled VC++ runtime, and the install completed anyway' + + # RELEASE-LAG PIN (overlay=false). See the Install step: this row installs unsloth + # from PyPI on purpose and the released setup.ps1 predates #7549. continue-on-error + # would otherwise tolerate a bootstrap outage or an unrelated early exit exactly like + # the intended diagnostic, so every branch here but the pinned failure exits 1 and + # fails the (required) job. + - name: Assert the released-wheel row failed only on release lag + if: always() && !matrix.overlay && steps.install.outcome != 'skipped' + shell: pwsh + run: | + if ('${{ steps.install.outcome }}' -eq 'success') { + Write-Host '::error::the released wheel now installs in a virgin container, so it carries the relaxed #7549 gates. Delete this pin and drop continue-on-error from the Install step so this row gates.' + exit 1 + } + if (-not (Test-Path logs/install-outer.log)) { + Write-Host '::error::the container install produced no log' + exit 1 + } + $log = Get-Content logs/install-outer.log -Raw + # The pinned signature is the OLD gate wording, which #7549 deleted. Its + # disappearance from a released wheel is the flip condition; until then a failure + # anywhere else has to be red. + $gitGate = $log -match 'Git is required but could not be installed automatically' + $vcGate = $log -match 'torch failed to import' + if (-not ($gitGate -or $vcGate)) { + Write-Host '::error::the container install failed at neither the released winget-only git gate nor the missing VC++ runtime; this is a new failure' + exit 1 + } + # `-or` on its own is too generous. virgin-windows-install.ps1:97 runs the torch + # assertion whenever the venv interpreter exists, whatever the installer did, and + # this image has no VC++ runtime, so ANY failure after venv creation -- a Node + # download, a setup step, a bad prebuilt -- arrives here carrying the $vcGate + # text and was accepted as the pinned outcome. Enumerate what the harness + # actually recorded instead: it prints one `::error::` per entry of its + # $failures list (that script:151), and every one has to be a pinned gate. + # Anchored, because it also dumps the install log tail indented two spaces and + # those copies must not count. + $recorded = @(Get-Content logs/install-outer.log | + ForEach-Object { if ($_ -match '^::error::(.+)$') { $Matches[1].Trim() } }) + Write-Host "recorded failures: $($recorded.Count)" + $recorded | ForEach-Object { Write-Host " $_" } + if ($recorded.Count -eq 0) { + Write-Host '::error::the container install failed but recorded no ::error:: line, so nothing identifies which gate stopped it' + exit 1 + } + # The git gate makes install.ps1 exit non-zero, the missing runtime makes the + # torch assert fail. Nothing else is pinned. + $pinned = @('^installer exited \d+$', '^torch failed to import from the managed Python') + $unexpected = @($recorded | Where-Object { $r = $_; -not ($pinned | Where-Object { $r -match $_ }) }) + if ($unexpected.Count -gt 0) { + Write-Host "::error::the container install recorded a failure outside the pinned gates: $($unexpected -join '; '); this is a new failure" + exit 1 + } + # And a non-zero exit has to BE the git gate: without this a post-venv failure + # that also exits 1 is indistinguishable from the pinned one. + if (($recorded | Where-Object { $_ -like 'installer exited*' }) -and + -not ($gitGate -and ($log -match 'unsloth studio setup failed \(exit code 1\)'))) { + Write-Host '::error::the installer exited non-zero somewhere other than the winget-only git gate in the released studio/setup.ps1; this is a new failure' + exit 1 + } + Write-Host '::notice::known outcome: the released wheel still carries the winget-only git gate and the winget-only Ensure-VCRedist that #7549 replaced. Retire this pin with the next release.' + + - name: Recover the install log from the container + if: always() + shell: pwsh + run: | + docker cp virgin:C:\ci-out\install.log logs/install.log 2>&1 | Out-Null + if (Test-Path logs/install.log) { Write-Host "recovered $((Get-Item logs/install.log).Length) bytes" } + else { Write-Host '::warning::no install log inside the container' } + exit 0 + + - name: Tear down + if: always() + shell: pwsh + run: | + docker rm -f virgin 2>&1 | Out-Null + exit 0 + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: virgin-win-container-overlay-${{ matrix.overlay }} + path: logs/ + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/desktop-app-clean-machine-ci.yml b/.github/workflows/desktop-app-clean-machine-ci.yml new file mode 100644 index 0000000000..7e95aa8c26 --- /dev/null +++ b/.github/workflows/desktop-app-clean-machine-ci.yml @@ -0,0 +1,740 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Installs and launches the SHIPPED desktop app on a machine stripped of developer +# tooling, on all three platforms. +# +# studio-tauri-smoke.yml compiles the Tauri crate and release-desktop.yml produces the +# bundles, but neither puts a published artifact on a clean machine and checks that it +# starts -- the gap the reported failures fell through: both came from the packaged app +# running its bundled Contents/Resources/install.sh, which no CI job exercised. +# +# Hosted runners have no interactive desktop session, so "runs" means: the bundle +# installs, the binary is present, of the right architecture, and clears the gatekeeper +# checks a user hits (macOS quarantine + codesign, Windows installer exit); the process +# STAYS UP past its preflight, where an unhappy app dies; and it writes tauri.log with a +# preflight disposition, the field that read `ManagedReady` over an unbootable venv in the +# bug report. Linux gets the strongest check: a real webview under Xvfb. + +name: Desktop app clean machine + +on: + # Also on PRs touching this job or the stripping scripts: dispatch resolves the workflow + # from the DEFAULT branch, so a new or edited file on a feature branch can never be + # dispatched and would first run only after merging blind. + pull_request: + paths: + - '.github/workflows/desktop-app-clean-machine-ci.yml' + - '.github/scripts/clean-machine-env.sh' + - '.github/scripts/clean-machine-assert.sh' + workflow_dispatch: + inputs: + release_tag: + description: 'Release tag in the desktop release repo' + type: string + default: 'desktop-v0.1.50-beta' + release_repo: + description: 'owner/name hosting the desktop release' + type: string + default: '' + strip_toolchain: + description: 'Strip developer tooling before installing' + type: boolean + default: true + schedule: + # Nightly, so a broken published bundle is caught without anyone asking. + - cron: '17 5 * * *' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + # Drafts are listed only to a token with push access, and every desktop-v* release here + # is a draft, so `contents: read` cannot see the bundle under test at all. + contents: write + +env: + # release-desktop.yml publishes into github.repository, so a nightly aimed anywhere else + # goes green over a broken production bundle. unsloth-test/unsloth-test holds one frozen + # release, so the schedule was re-testing the same fixture forever. + REL_REPO: ${{ inputs.release_repo || github.repository }} + # Empty unless dispatched: a pinned tag is an immutable fixture, so a nightly against it + # could never catch a newly published broken bundle. Each download step then resolves + # the newest desktop-v* release, drafts included -- every desktop-v* release here is cut + # as a draft, so --exclude-drafts matched nothing and every leg died resolving. + # releases/tags/ 404s for a draft, but gh looks drafts up over GraphQL, so `gh + # release download ` still fetches their assets. + REL_TAG: ${{ inputs.release_tag || '' }} + UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home + UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1' + +jobs: + # ── macOS: .dmg, Apple Silicon ──────────────────────────────────────────── + macos: + # A fork PR's token is read-only however this workflow declares permissions, so it + # cannot list the draft releases every desktop-v* bundle is published as. Skip rather + # than fail: a property of the trigger, not a broken release. + if: github.event.pull_request.head.repo.fork != true + name: desktop macOS ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + include: + - {os: macos-14, experimental: false} + - {os: macos-15, experimental: false} + - {os: macos-26, experimental: true} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download the shipped .dmg + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p dl logs + # Desktop releases are prereleases (never repo-wide "latest") and drafts, so the + # newest desktop-v* tag has to be resolved explicitly. See REL_TAG above. + if [ -z "$REL_TAG" ]; then + REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \ + --json tagName,createdAt \ + --jq '[.[] | select(.tagName | startswith("desktop-v"))] + | sort_by(.createdAt) | reverse | .[0].tagName // empty')" + # Loud on purpose: there is no bundle to test, so passing would prove nothing. + [ -n "$REL_TAG" ] || { + echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)" + exit 1 + } + echo "resolved release tag: $REL_TAG" + echo "REL_TAG=$REL_TAG" >> "$GITHUB_ENV" + fi + gh release download "$REL_TAG" --repo "$REL_REPO" \ + --pattern '*aarch64.dmg' --dir dl + ls -la dl + + - name: Strip the developer toolchain + # `inputs` exists only for workflow_dispatch, so elsewhere strip_toolchain is '' -- + # and loose equality coerces both '' and false to 0, making `!= false` FALSE, so + # automatic runs would keep the very toolchain this removes. Gate on the event. + if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }} + run: | + bash .github/scripts/clean-machine-env.sh mask --remove + set -a; . ./clean-machine.env; set +a + bash .github/scripts/clean-machine-assert.sh absent + + - name: Mount and install + run: | + DMG="$(ls dl/*.dmg | head -1)" + # A real download is quarantined, and Gatekeeper treats that differently from a + # locally built bundle: a genuine failure mode. + xattr -w com.apple.quarantine \ + "0081;$(printf %x $(date +%s));Safari;" "$DMG" 2>/dev/null || true + hdiutil attach "$DMG" -nobrowse -quiet -mountpoint /Volumes/UnslothCI + APP="$(ls -d /Volumes/UnslothCI/*.app | head -1)" + echo "app bundle: $APP" + cp -R "$APP" /Applications/ + hdiutil detach /Volumes/UnslothCI -quiet + ls -la /Applications | grep -i unsloth + + - name: Inspect the bundle (arch, signature, Gatekeeper) + run: | + APP="$(ls -d /Applications/*Unsloth*.app | head -1)" + BIN="$APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist")" + file "$BIN" + # `lipo -archs` prints and exits 0 for a thin x86_64 binary, and `|| true` + # swallowed even that, so architecture was never asserted. lipo is an xcrun shim, + # gone once the strip moved CommandLineTools aside; file is base system. + ARCHS="$(lipo -archs "$BIN" 2>/dev/null || true)" + [ -n "$ARCHS" ] || ARCHS="$(file -b "$BIN")" + echo "architectures: $ARCHS" + case "$ARCHS" in + *arm64*|*aarch64*) ;; + *) echo "::error::the aarch64 .dmg carries no arm64 binary ($ARCHS)"; exit 1 ;; + esac + # Report rather than gate: an unnotarised beta is expected to fail assessment, but + # a user WILL hit this, so it must be visible. + codesign -dv --verbose=2 "$APP" 2>&1 | head -20 || true + spctl -a -vvv -t install "$APP" 2>&1 | head -5 || \ + echo "::warning::Gatekeeper assessment failed -- users see 'cannot be opened' unless notarised" + # The bundled installer is what actually failed for users, and `::error::` is only + # an annotation that `echo` exits 0 from, so `|| echo` let a bundle with no + # installer pass. + if [ -f "$APP/Contents/Resources/install.sh" ]; then + echo "bundled install.sh present" + else + echo "::error::no bundled install.sh in the app" + exit 1 + fi + + - name: Run the bundled installer, the path first launch takes + run: | + set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a + set -o pipefail + APP="$(ls -d /Applications/*Unsloth*.app | head -1)" + # A headless runner never clicks Install: preflight sets `not_installed` and + # returns (use-tauri-backend.ts:252-254) while startup-screen.tsx:388-389 waits + # for the button, so launching alone sits there for 90s without ever running the + # bundled installer. Invoke it as src-tauri/src/install.rs does: --tauri, stdin + # closed, no tty. --tauri rejects a custom studio home (install.sh:102-114), so + # drop the override. + # KNOWN OUTCOME PIN, retire when the desktop release catches up to #7547. REL_TAG + # predates #7547, so the bundle's own install.sh still hard-exits on the Xcode + # CLT gate that #7547 replaced with a warning. Only a new release can move that, + # not this PR. _check_macos_deps is the function #7547 added, so finding it means + # the release caught up and this pin must go. + SH="$APP/Contents/Resources/install.sh" + if grep -q '_check_macos_deps' "$SH"; then + echo "::error::the bundled install.sh now carries #7547; delete this pin block and let the venv + torch assertions below run unconditionally" + exit 1 + fi + rc=0 + env -u UNSLOTH_STUDIO_HOME \ + bash "$SH" --tauri \ + < /dev/null 2>&1 | tee logs/bundled-install.log || rc=$? + echo "bundled installer exit code: $rc" + # Exit code AND the exact gate line, so any other non-zero exit still fails. + if [ "$rc" -eq 1 ] && grep -qE '^==> Xcode Command Line Tools are required\.[[:space:]]*$' logs/bundled-install.log; then + echo "::notice::known pre-#7547 outcome: the shipped bundle's install.sh stopped on the Xcode CLT gate and exited 1. Not a regression here; the next desktop release retires this pin." + exit 0 + fi + [ "$rc" -eq 0 ] || { + echo "::error::bundled installer exited $rc, which is neither success nor the pinned pre-#7547 outcome (exit 1 plus '==> Xcode Command Line Tools are required.')" + exit 1 + } + PY="$HOME/.unsloth/studio/unsloth_studio/bin/python" + [ -x "$PY" ] || { echo "::error::bundled installer left no venv at $PY"; exit 1; } + "$PY" -V + # install.rs passes only --tauri, so torch is part of first launch: without this + # the venv check passes a bundle whose only failure is the torch install. + "$PY" -c "import torch; print('torch', torch.__version__)" + + - name: Launch and prove it stays up + run: | + set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a + APP="$(ls -d /Applications/*Unsloth*.app | head -1)" + BIN="$APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist")" + "$BIN" > logs/app-stdout.log 2>&1 & + APP_PID=$! + # 90s: long enough to clear preflight and start the bundled installer. + for i in $(seq 1 90); do + kill -0 "$APP_PID" 2>/dev/null || break + sleep 1 + done + if kill -0 "$APP_PID" 2>/dev/null; then + echo "app still running after 90s (pid $APP_PID)" + kill -TERM "$APP_PID" 2>/dev/null || true + else + wait "$APP_PID" 2>/dev/null; rc=$? + echo "::error::desktop app exited early with rc=$rc" + tail -50 logs/app-stdout.log || true + exit 1 + fi + + - name: What did its own log say? + if: always() + run: | + for f in "$UNSLOTH_STUDIO_HOME/tauri.log" "$HOME/.unsloth/studio/tauri.log"; do + [ -f "$f" ] || continue + echo "=== $f ===" + cp "$f" logs/ 2>/dev/null || true + tail -60 "$f" + # The two fields the bug report turned on. + grep -E "disposition=|can_auto_repair=|Xcode Command Line|ModuleNotFoundError" "$f" || true + found=1 + if grep -qE "desktop_preflight completed disposition=" "$f"; then disposition=1; fi + done + # Everything above is `|| true`, so this step could not fail while the header + # sells the tauri.log disposition as an acceptance criterion. setup_logging + # (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally at process start, + # so no log means the binary never got that far, and the disposition line is the + # field the bug report turned on: a process that hangs before preflight must not + # pass. + [ "${found:-0}" = "1" ] || { echo "::error::the app wrote no tauri.log; it never reached setup_logging"; exit 1; } + [ "${disposition:-0}" = "1" ] || { echo "::error::tauri.log records no desktop_preflight disposition; the app never completed preflight"; exit 1; } + + - name: Restore the runner + if: always() + # `|| true` swallowed everything, a genuinely broken restore included. The file + # only exists once the strip step ran, and an earlier step can fail before that, + # so skip explicitly when it is absent and let a real failure surface. + run: | + if [ -f .clean-machine/restore.sh ]; then + bash .clean-machine/restore.sh + else + echo "no .clean-machine/restore.sh: the toolchain was never stripped, nothing to restore" + fi + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-macos-${{ matrix.os }} + path: logs/ + retention-days: 7 + if-no-files-found: warn + + # ── Linux: .deb and .AppImage, with a real webview under Xvfb ──────────── + linux: + # See the macOS job: a fork PR's token cannot list drafts, so skip rather than fail. + if: github.event.pull_request.head.repo.fork != true + name: desktop linux ${{ matrix.kind }} + runs-on: ubuntu-22.04 + timeout-minutes: 45 + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + include: + - {kind: deb, experimental: false} + - {kind: appimage, experimental: false} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download the shipped bundle + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p dl logs + # See the macOS job. Loud on purpose: no bundle means nothing to prove. + if [ -z "$REL_TAG" ]; then + REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \ + --json tagName,createdAt \ + --jq '[.[] | select(.tagName | startswith("desktop-v"))] + | sort_by(.createdAt) | reverse | .[0].tagName // empty')" + [ -n "$REL_TAG" ] || { + echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)" + exit 1 + } + echo "resolved release tag: $REL_TAG" + echo "REL_TAG=$REL_TAG" >> "$GITHUB_ENV" + fi + pat='*.deb'; [ "${{ matrix.kind }}" = "appimage" ] && pat='*.AppImage' + gh release download "$REL_TAG" --repo "$REL_REPO" --pattern "$pat" --dir dl + ls -la dl + + - name: Strip the developer toolchain + # Same gate as macOS: without it the Linux rows ignored strip_toolchain and ran the + # bundled installer with the runner's git, gcc, cmake and make in /usr/bin. + # + # BEFORE the bundle install, as macOS and Windows already do: dpkg runs the + # package's own maintainer scripts, so installing first let them see the hosted + # image's toolchain. Nothing in that install needs a masked tool -- + # clean-machine-env.sh moves aside only $TOOLS, leaving the package manager itself + # -- and the current bundle ships a postrm and no install-time script. + if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }} + run: | + bash .github/scripts/clean-machine-env.sh mask --remove + set -a; . ./clean-machine.env; set +a + bash .github/scripts/clean-machine-assert.sh absent + + - name: Install with NO dev tooling, only runtime libs + run: | + # Deliberately not build-essential/cmake/git: a user installing a .deb has none + # of that. Xvfb and WebKit are runtime requirements, and apt pulls the .deb's + # declared deps, so a wrong dependency list fails here. + sudo apt-get update -qq + sudo apt-get install -y -qq --no-install-recommends xvfb + if [ "${{ matrix.kind }}" = "deb" ]; then + sudo apt-get install -y ./dl/*.deb || { + echo "::error::the .deb does not declare its runtime dependencies correctly" + exit 1 + } + BIN="$(dpkg -L "$(dpkg-deb -f dl/*.deb Package)" | grep -E '/usr/bin/' | head -1)" + else + sudo apt-get install -y -qq --no-install-recommends libfuse2 \ + libwebkit2gtk-4.1-0 libgtk-3-0 libayatana-appindicator3-1 || true + chmod +x dl/*.AppImage + BIN="$(ls dl/*.AppImage | head -1)" + fi + echo "BIN=$BIN" >> "$GITHUB_ENV" + echo "binary: $BIN" + + # The strip runs before this, but `apt-get install ./dl/*.deb` then pulls the bundle's + # DECLARED dependencies, so a release that adds git, cmake or a compiler to that list + # puts one back in /usr/bin and both required Linux rows still pass. `absent` ran only + # beforehand, so re-run it here, before the bundled installer. The current dependency + # closure is 65 packages of runtime libs and no toolchain, so this is green today and + # only a new dependency can turn it red. + - name: Re-assert the toolchain is still absent after the package install + if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }} + run: | + set -a; . ./clean-machine.env; set +a + bash .github/scripts/clean-machine-assert.sh absent + + - name: Run the bundled installer, the path first launch takes + run: | + set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a + set -o pipefail + # The launch step below only proves the process stayed alive: on a fresh home + # preflight reports not_installed and the app waits on the install screen for a + # click (use-tauri-backend.ts:252-254, startup-screen.tsx:388-389), so a bundle + # whose embedded install.sh was missing or broken passed both Linux rows. + # tauri.conf.json:56-59 ships it as a bundle resource, so find it there and run + # it as install.rs does. + if [ "${{ matrix.kind }}" = "deb" ]; then + SH="$(dpkg -L "$(dpkg-deb -f dl/*.deb Package)" | grep -E '/install\.sh$' | head -1)" + else + # ls returns a bare filename here, and a command word with no slash resolves + # through PATH, not the cwd, so this needs the ./ prefix. + (cd dl && "./$(ls *.AppImage | head -1)" --appimage-extract >/dev/null) + SH="$(find dl/squashfs-root -name install.sh -type f | head -1)" + fi + [ -n "$SH" ] && [ -f "$SH" ] || { echo "::error::the bundle ships no install.sh resource"; exit 1; } + echo "bundled installer: $SH" + # KNOWN OUTCOME PIN, retire when the desktop release catches up to #7547. The + # bundle carries its own install.sh and REL_TAG predates #7547, so on a stripped + # runner it still exits 2 at the NEED_SUDO handshake for the optional set instead + # of falling through to prebuilt llama.cpp. Only a new release can move that, not + # this PR. _SMART_APT_OPTIONAL is the guard #7547 added, so finding it means the + # release caught up and this pin must go. + if grep -q '_SMART_APT_OPTIONAL' "$SH"; then + echo "::error::the bundled install.sh now carries #7547; delete this pin block and let the venv + torch assertions below run unconditionally" + exit 1 + fi + # --tauri rejects a custom studio home (install.sh:102-114), so drop the + # workspace-scoped override; close stdin as install.rs does. + rc=0 + env -u UNSLOTH_STUDIO_HOME \ + bash "$SH" --tauri < /dev/null 2>&1 | tee logs/bundled-install.log || rc=$? + echo "bundled installer exit code: $rc" + # Exit code AND the exact optional set, so a different NEED_SUDO list or any other + # non-zero exit is still a failure. + if [ "$rc" -eq 2 ] && grep -qE '^\[TAURI:NEED_SUDO\] cmake git build-essential libcurl4-openssl-dev[[:space:]]*$' logs/bundled-install.log; then + echo "::notice::known pre-#7547 outcome: the shipped bundle's install.sh asked to elevate for the optional set and exited 2. Not a regression here; the next desktop release retires this pin." + exit 0 + fi + [ "$rc" -eq 0 ] || { + echo "::error::bundled installer exited $rc, which is neither success nor the pinned pre-#7547 outcome (exit 2 plus exactly '[TAURI:NEED_SUDO] cmake git build-essential libcurl4-openssl-dev')" + exit 1 + } + PY="$HOME/.unsloth/studio/unsloth_studio/bin/python" + [ -x "$PY" ] || { echo "::error::bundled installer left no venv at $PY"; exit 1; } + "$PY" -V + # install.rs passes only --tauri, so torch is part of first launch. + "$PY" -c "import torch; print('torch', torch.__version__)" + + - name: Launch under Xvfb and prove it stays up + run: | + set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a + # Linux is the one platform where a hosted runner can give the app a real display, + # so this is the strongest "does the UI come up" check available without + # self-hosted hardware. + xvfb-run -a --server-args="-screen 0 1440x900x24" \ + "$BIN" > logs/app-stdout.log 2>&1 & + APP_PID=$! + for i in $(seq 1 90); do + kill -0 "$APP_PID" 2>/dev/null || break + sleep 1 + done + if kill -0 "$APP_PID" 2>/dev/null; then + echo "app still running after 90s" + kill -TERM "$APP_PID" 2>/dev/null || true + else + wait "$APP_PID" 2>/dev/null; rc=$? + echo "::error::desktop app exited early with rc=$rc" + tail -60 logs/app-stdout.log || true + exit 1 + fi + + - name: What did its own log say? + if: always() + run: | + for f in "$UNSLOTH_STUDIO_HOME/tauri.log" "$HOME/.unsloth/studio/tauri.log"; do + [ -f "$f" ] || continue + echo "=== $f ==="; cp "$f" logs/ 2>/dev/null || true; tail -60 "$f" + grep -E "disposition=|can_auto_repair=|ModuleNotFoundError" "$f" || true + found=1 + if grep -qE "desktop_preflight completed disposition=" "$f"; then disposition=1; fi + done + # Same acceptance criterion the macOS rows enforce, and for the same reason: + # everything above is `|| true` and the loop skips a missing log, so without + # these two lines the step could not fail. + [ "${found:-0}" = "1" ] || { echo "::error::the app wrote no tauri.log; it never reached setup_logging"; exit 1; } + [ "${disposition:-0}" = "1" ] || { echo "::error::tauri.log records no desktop_preflight disposition; the app never completed preflight"; exit 1; } + + - name: Restore the runner + if: always() + # See the macOS job: `|| true` would swallow a genuinely broken restore. + run: | + if [ -f .clean-machine/restore.sh ]; then + bash .clean-machine/restore.sh + else + echo "no .clean-machine/restore.sh: the toolchain was never stripped, nothing to restore" + fi + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-linux-${{ matrix.kind }} + path: logs/ + retention-days: 7 + if-no-files-found: warn + + # ── Windows: NSIS setup.exe, silent install ────────────────────────────── + windows: + # See the macOS job: a fork PR's token cannot list drafts, so skip rather than fail. + if: github.event.pull_request.head.repo.fork != true + name: desktop windows + runs-on: windows-latest + # 60, not 45: this job runs the bundled installer, and a full torch install on a + # Windows runner is the slowest of the three platforms. + timeout-minutes: 60 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download the shipped installer + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p dl logs + # See the macOS job. Loud on purpose: no bundle means nothing to prove. + if [ -z "$REL_TAG" ]; then + REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \ + --json tagName,createdAt \ + --jq '[.[] | select(.tagName | startswith("desktop-v"))] + | sort_by(.createdAt) | reverse | .[0].tagName // empty')" + [ -n "$REL_TAG" ] || { + echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)" + exit 1 + } + echo "resolved release tag: $REL_TAG" + echo "REL_TAG=$REL_TAG" >> "$GITHUB_ENV" + fi + gh release download "$REL_TAG" --repo "$REL_REPO" --pattern '*setup.exe' --dir dl + ls -la dl + + - name: Strip the developer toolchain + # Same gate as macOS, for the same `inputs`-coercion reason. + if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }} + shell: pwsh + run: | + $drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\', 'CMake', + 'Microsoft Visual Studio', 'BuildTools', 'LLVM', 'MSYS', 'mingw') + # winget is an app-execution alias under ...\Local\Microsoft\WindowsApps, so the + # WindowsApps fragment -- there to take the Store's python.exe alias away -- + # drops the OS package manager with it. winget is not developer tooling: every + # consumer Windows machine this bundle ships to has it, and the bundled + # install.ps1 reaches for it for the git that studio/setup.ps1:1657-1669 still + # gates on unconditionally. Without it this lane only re-runs, as a hard failure, + # the no-winget fallback clean-machine-install-ci.yml already covers and pins on + # its winget=masked row. Resolve winget before the scrub and hand it back through + # a shim, exactly as that workflow does. + $wingetCmd = Get-Command winget -ErrorAction SilentlyContinue + if (-not $wingetCmd) { + Write-Host '::error::winget was not on PATH before the strip; this image ships it and the bundled installer needs it' + exit 1 + } + $shim = Join-Path $env:RUNNER_TEMP 'winget-shim' + New-Item -ItemType Directory -Force -Path $shim | Out-Null + Set-Content -LiteralPath (Join-Path $shim 'winget.cmd') -Encoding ascii ` + -Value "@`"$($wingetCmd.Source)`" %*" + $scrub = { + param($entries) + ,@($entries | Where-Object { $p = $_; $p -and -not ($drop | Where-Object { $p -like "*$_*" }) }) + } + "PATH=$shim;$((& $scrub ($env:PATH -split ';')) -join ';')" | + Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + # Off disk, not just off PATH: py.exe lives in C:\Windows (which must stay) and + # uv does its own discovery, so both reach the toolcache whatever PATH says. + foreach ($tc in @("$env:AGENT_TOOLSDIRECTORY\Python", 'C:\hostedtoolcache\windows\Python')) { + if ($tc -and (Test-Path $tc)) { + try { Rename-Item -LiteralPath $tc -NewName 'Python.masked' -ErrorAction Stop + Write-Host "masked toolcache python: $tc" } + catch { Write-Host "::error::could not mask $tc ($($_.Exception.Message)); the job would not be clean"; exit 1 } + } + } + # The bundled install.ps1 this job runs calls Refresh-SessionPath (318-337), which + # merges the Machine and User registry PATHs back into $env:Path, so a + # process-only scrub lasts until the first refresh and Git/CMake/VS/LLVM come back + # from the registry. The runner is ephemeral, so rewrite the registry copies too. + # (A merge keeps what the process already had, which is why the winget shim above + # survives.) Expand first: SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ + # (dotnet/runtime#1442). + foreach ($scope in 'Machine','User') { + $raw = [System.Environment]::GetEnvironmentVariable('Path', $scope) + if ([string]::IsNullOrWhiteSpace($raw)) { continue } + $expanded = [System.Environment]::ExpandEnvironmentVariables($raw) -split ';' + try { + [System.Environment]::SetEnvironmentVariable('Path', ((& $scrub $expanded) -join ';'), $scope) + } catch { + Write-Host "::error::could not scrub the $scope PATH ($($_.Exception.Message)); the strip would not survive Refresh-SessionPath" + exit 1 + } + } + foreach ($v in 'VSINSTALLDIR','VCINSTALLDIR','WindowsSdkDir','INCLUDE','LIB','LIBPATH') { + "$v=" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + } + exit 0 + + - name: Verify the strip took effect + # PATH written to $GITHUB_ENV only applies to LATER steps, so the scrub can only be + # checked from here. The drop list above is heuristic path-fragment matching: if a + # runner image moves any of these tools outside those fragments, the bundled + # install.ps1 reuses the survivor and this job still calls itself clean. Same + # assertion the installer workflow runs, same reason. + if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }} + shell: pwsh + run: | + $leaked = @() + foreach ($t in 'python','py','git','cmake','cl') { + $f = Get-Command $t -ErrorAction SilentlyContinue + Write-Host ("{0,-8} {1}" -f $t, $(if ($f) { $f.Source } else { 'ABSENT' })) + if ($f -and $t -ne 'py') { $leaked += "$t -> $($f.Source)" } + } + # `py` itself lives in C:\Windows and stays. Only an interpreter it can still + # START is a leak, because Find-CompatiblePython (install.ps1:1130-1153) probes + # `py` first. `py -0p` is only the launcher's REGISTRY view, which still names the + # paths the rename removed, so a start attempt is the only real evidence. + if (Get-Command py -ErrorAction SilentlyContinue) { + foreach ($v in '-3.11', '-3.12', '-3.13') { + $out = & py $v -c "import sys; print(sys.executable)" 2>&1 + $rc = $LASTEXITCODE + Write-Host ("py {0} -> exit {1}: {2}" -f $v, $rc, (($out | Out-String).Trim() -replace '\r?\n', ' / ')) + if ($rc -eq 0) { $leaked += "py $v -> $out" } + } + # A failing probe is the outcome we want, but it leaves $LASTEXITCODE non-zero + # and the runner appends `exit $LASTEXITCODE` to every pwsh step + # (actions/runner#351), so the step would exit 1 on a machine that is clean. + $global:LASTEXITCODE = 0 + } + # The shim is the only reason winget resolves after the WindowsApps drop. It + # survives the installer's own refreshes because Refresh-SessionPath + # (install.ps1:318-337) and setup.ps1's Refresh-Environment MERGE the current + # $env:Path back in rather than replace it -- but assert that, or this lane + # silently degrades into the no-winget leg the installer workflow already pins. + $winget = Get-Command winget -ErrorAction SilentlyContinue + Write-Host ("winget {0}" -f $(if ($winget) { $winget.Source } else { 'ABSENT' })) + if (-not $winget) { + Write-Host '::error::winget did not survive the strip; the bundled installer would take the no-winget fallback instead of the consumer path' + exit 1 + } + if ($leaked) { + Write-Host "::error::developer tooling survived the strip: $($leaked -join '; ')" + exit 1 + } + exit 0 + + - name: Silent install + shell: pwsh + run: | + $exe = (Get-ChildItem dl/*setup.exe | Select-Object -First 1).FullName + # /S is the NSIS silent switch: a user double-clicks, but an installer that cannot + # run unattended cannot be scripted or MDM-deployed either. + $p = Start-Process -FilePath $exe -ArgumentList '/S' -Wait -PassThru + Write-Host "installer exit: $($p.ExitCode)" + if ($p.ExitCode -ne 0) { Write-Host "::error::silent install failed"; exit 1 } + $found = Get-ChildItem -Path "$env:LOCALAPPDATA","$env:ProgramFiles" -Recurse ` + -Filter '*Unsloth*.exe' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $found) { Write-Host '::error::no installed executable found'; exit 1 } + Write-Host "installed: $($found.FullName)" + "APP_EXE=$($found.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Run the bundled installer, the path first launch takes + shell: pwsh + run: | + # The launch step below only proves the process stayed alive: on a fresh profile + # the app waits for a click on Install (use-tauri-backend.ts:252-254, + # startup-screen.tsx:388-389), so this job passed on a bundle whose embedded + # install.ps1 was missing or broken. tauri.conf.json:56-59 ships it as a bundle + # resource, so find it where NSIS put it and invoke it as install.rs:326-341. + $root = Split-Path -Parent $env:APP_EXE + $ps1 = Get-ChildItem -Path $root -Recurse -Filter 'install.ps1' -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $ps1) { + Write-Host '::error::the bundle ships no install.ps1 resource' + exit 1 + } + Write-Host "bundled installer: $($ps1.FullName)" + # --tauri rejects a custom studio home (install.ps1:189-215), so drop the + # override as install.rs does (354-357). + Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue + & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File $ps1.FullName --tauri *>&1 | Tee-Object -FilePath logs/bundled-install.log + $rc = $LASTEXITCODE + Write-Host "bundled installer exit: $rc" + if ($rc -ne 0) { + Write-Host "::error::bundled installer exited $rc" + exit $rc + } + # The exit code alone is not enough: it is the venv the app then boots from. + $py = Join-Path $env:USERPROFILE '.unsloth\studio\unsloth_studio\Scripts\python.exe' + if (-not (Test-Path $py)) { + Write-Host "::error::bundled installer left no venv at $py" + exit 1 + } + & $py -V + # install.rs passes only --tauri, so torch is part of first launch, and a venv that + # cannot import it is the unbootable environment from the report. + & $py -c "import torch; print('torch', torch.__version__)" + if ($LASTEXITCODE -ne 0) { + Write-Host '::error::the bundled install produced a venv with no working torch' + exit 1 + } + + - name: Launch and prove it stays up + shell: pwsh + run: | + $p = Start-Process -FilePath $env:APP_EXE -PassThru ` + -RedirectStandardOutput logs/app-stdout.log ` + -RedirectStandardError logs/app-stderr.log + for ($i = 0; $i -lt 90; $i++) { if ($p.HasExited) { break }; Start-Sleep -Seconds 1 } + if ($p.HasExited) { + Write-Host "::error::desktop app exited early with rc=$($p.ExitCode)" + Get-Content logs/app-stdout.log, logs/app-stderr.log -Tail 40 -ErrorAction SilentlyContinue + exit 1 + } + Write-Host "app still running after 90s" + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + + - name: What did its own log say? + if: always() + shell: pwsh + run: | + $found = $false; $disposition = $false + foreach ($f in @("$env:UNSLOTH_STUDIO_HOME\tauri.log", + "$env:USERPROFILE\.unsloth\studio\tauri.log")) { + if (Test-Path $f) { + Write-Host "=== $f ===" + Copy-Item $f logs/ -ErrorAction SilentlyContinue + Get-Content $f -Tail 60 + Select-String -Path $f -Pattern 'disposition=|can_auto_repair=|ModuleNotFoundError' ` + -ErrorAction SilentlyContinue + $found = $true + if (Select-String -Path $f -Pattern 'desktop_preflight completed disposition=' ` + -SimpleMatch -Quiet) { $disposition = $true } + } + } + # Same acceptance criterion macOS and Linux enforce: Test-Path, Get-Content and + # Select-String cannot fail, so without these two lines the step was decoration. + if (-not $found) { + Write-Host '::error::the app wrote no tauri.log; it never reached setup_logging' + exit 1 + } + if (-not $disposition) { + Write-Host '::error::tauri.log records no desktop_preflight disposition; the app never completed preflight' + exit 1 + } + exit 0 + + - name: Upload logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-windows + path: logs/ + retention-days: 7 + if-no-files-found: warn diff --git a/install.ps1 b/install.ps1 index 5b205df96d..11de4aec5d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -2761,6 +2761,37 @@ exit 0 } } + # ── CI only: overlay a source checkout over the package just installed ── + # Mirrors install.sh. Not a consumer knob: no switch, absent from the usage + # text, ignored unless UNSLOTH_CI_SOURCE_OVERLAY names a directory holding a + # pyproject.toml. + # + # The clean-machine legs run THIS script from a branch but install unsloth + # from PyPI, the consumer path, so everything Python-side (studio/setup.ps1, + # install_python_stack.py and every requirements/constraints file they reach + # via Path(__file__)) would be the released wheel's and a branch could not be + # validated. `& $UnslothExe studio setup` below goes through the CLI, and an + # editable overlay makes _PACKAGE_ROOT in unsloth_cli/commands/studio.py + # resolve to the working tree by PEP 660 __file__, so setup.ps1 comes from + # this ref. NOT --local: that also installs `unsloth-zoo @ + # git+https://github.com/unslothai/unsloth-zoo`, which genuinely needs git, + # and git absence is what the masked leg proves; editable + --no-deps + # resolves and clones nothing, so it survives git, cmake and MSVC all missing. + if ($env:UNSLOTH_CI_SOURCE_OVERLAY) { + $CiOverlayRoot = $env:UNSLOTH_CI_SOURCE_OVERLAY + if (-not (Test-Path -LiteralPath (Join-Path $CiOverlayRoot "pyproject.toml"))) { + Write-Host "[ERROR] UNSLOTH_CI_SOURCE_OVERLAY is set to '$CiOverlayRoot' but there is no pyproject.toml there." -ForegroundColor Red + return (Exit-InstallFailure "UNSLOTH_CI_SOURCE_OVERLAY has no pyproject.toml: $CiOverlayRoot") + } + substep "CI: overlaying source checkout (editable, no deps): $CiOverlayRoot" + # Retry: the editable build fetches its build backend from PyPI, same + # transient-network risk as every other step. + $CiOverlayExit = Invoke-InstallCommandRetry -Label "overlay CI source checkout" -Command { uv pip install --python $VenvPython --no-deps -e $CiOverlayRoot } + if ($CiOverlayExit -ne 0) { + return (Exit-InstallFailure "Failed to overlay the CI source checkout (exit code $CiOverlayExit)" $CiOverlayExit) + } + } + # ── Run studio setup ── # setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools, # CUDA Toolkit, and other dependencies automatically via winget. Node.js is diff --git a/install.sh b/install.sh index 166beeb52c..d474a6becf 100755 --- a/install.sh +++ b/install.sh @@ -4185,6 +4185,32 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then fi fi +# ── CI only: overlay a source checkout over the package just installed ── +# Not a consumer knob: no flag, absent from --help, ignored unless +# UNSLOTH_CI_SOURCE_OVERLAY names a directory holding a pyproject.toml. +# +# The clean-machine legs run THIS script from a branch but install unsloth from +# PyPI, the consumer path, so everything Python-side (studio/setup.sh, setup.ps1, +# install_python_stack.py and every requirements/constraints file they reach via +# Path(__file__)) would be the released wheel's and a branch could not be +# validated. An editable overlay re-points `import studio` at the working tree, so +# the importlib.resources lookup below finds this ref's setup.sh. NOT --local: +# that also installs `unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo`, +# which genuinely needs git, and git absence is what these legs prove; editable + +# --no-deps resolves and clones nothing, so it survives git, cmake and the C/C++ +# compilers all being gone. +if [ -n "${UNSLOTH_CI_SOURCE_OVERLAY:-}" ]; then + if [ ! -f "$UNSLOTH_CI_SOURCE_OVERLAY/pyproject.toml" ]; then + echo "[ERROR] UNSLOTH_CI_SOURCE_OVERLAY is set to '$UNSLOTH_CI_SOURCE_OVERLAY' but there is no pyproject.toml there." >&2 + exit 1 + fi + substep "CI: overlaying source checkout (editable, no deps): $UNSLOTH_CI_SOURCE_OVERLAY" + # Retry: the editable build fetches its build backend from PyPI, same + # transient-network risk as every other install step. + run_install_cmd_retry "overlay CI source checkout" uv pip install --python "$_VENV_PY" \ + --no-deps -e "$UNSLOTH_CI_SOURCE_OVERLAY" +fi + # ── Run studio setup ── tauri_log "STEP" "Running Unsloth setup" # When --local, use the repo's own setup.sh directly. diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index 886abe218b..bac5773625 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -2790,6 +2790,9 @@ def pip_install_try( env = _install_env_for_cmd(cmd), ) if result.returncode == 0: + # As pip_install below: `nobuild` only catches a build that reaches the log. + if VERBOSE and result.stdout: + print(_redact_install_output(result.stdout)) return True if VERBOSE and result.stdout: # pip/uv echo index URLs (credentials included) in failure output. @@ -2845,6 +2848,15 @@ def pip_install( **_windows_hidden_subprocess_kwargs(), ) if result.returncode == 0: + # Echo success under UNSLOTH_VERBOSE, as install.sh's + # run_install_cmd does. Without it the dependency phase never + # reached the install log that clean-machine-assert.sh's `nobuild` + # greps for uv's "Building ==", so a source build in this + # step -- the studio.txt install, where sdist-only dependencies + # actually show up -- reported "built: none" and stayed green. + # Redacted: uv echoes index URLs with credentials. + if VERBOSE and result.stdout: + print(_redact_install_output(result.stdout)) return print(_red(f" uv failed, falling back to pip...")) if result.stdout: