From f09a1e71a961ab008bcc721b42f1f9ea84620965 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Wed, 29 Jul 2026 06:33:16 +0000 Subject: [PATCH] Tighten the clean-machine CI comments Comments only, no assertion logic, pins or leg definitions touched. Reflowed every rationale block to denser wording and removed the duplication that had built up across repeated steps: the desktop workflow repeated the fork-PR skip, the desktop-v* tag resolution and the restore-runner note once per platform, and the installer workflow repeated its path-filter rationale in both the pull_request and push blocks. Those now point at the first copy. Every WHY is kept: why the masked legs avoid install.sh --local, what UNSLOTH_CI_SOURCE_OVERLAY is for, why `absent` tests "must not work" rather than command -v, why the .venv_t5_* sidecars are in the macho scan scope, why the signature check is main-executables-only, why each nobuild allowlist entry is a pure-Python sdist, why the WSL job gates and what the pipe truncation was, and why the virgin container's overlay=false row is still pinned. Proved comments-only three ways: both workflow revisions parsed with yaml.safe_load_all and every leaf walked (only `run:` scalars differ); every changed bash body and .sh compared byte-for-byte after `bash --pretty-print -n`; every changed pwsh body and .ps1 compared as a token stream with Comment and NewLine tokens dropped. A negative control injecting one non-comment line into each layer makes all of them fail. --- .github/scripts/assert-nobuild.ps1 | 22 +- .github/scripts/clean-machine-assert.sh | 81 +- .github/scripts/clean-machine-env.sh | 22 +- .github/scripts/ensure-docker-daemon.ps1 | 18 +- .github/scripts/virgin-windows-install.ps1 | 42 +- .github/scripts/virgin-windows-probe.ps1 | 56 +- .../workflows/clean-machine-install-ci.yml | 720 +++++++++--------- .../desktop-app-clean-machine-ci.yml | 273 +++---- install.ps1 | 27 +- install.sh | 23 +- studio/install_python_stack.py | 16 +- 11 files changed, 620 insertions(+), 680 deletions(-) diff --git a/.github/scripts/assert-nobuild.ps1 b/.github/scripts/assert-nobuild.ps1 index c9937bfd95..7ac86cb5d2 100644 --- a/.github/scripts/assert-nobuild.ps1 +++ b/.github/scripts/assert-nobuild.ps1 @@ -1,12 +1,11 @@ # 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 clean-machine scrub drops every `*\Git\*` PATH -# entry, and the bash version needs sed/grep/tr/sort out of Git's usr/bin. It also runs -# inside the servercore container, which has no bash at all. Both Windows lanes call -# this one file so the sdist allowlist cannot drift. +# 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()] @@ -17,9 +16,10 @@ if (-not (Test-Path -LiteralPath $LogPath)) { exit 1 } -# "Built an sdist" is NOT "needed a compiler": every name here has a -# setuptools.build_meta backend, no ext_modules and no .c/.cpp/.pyx/.rs file, so its -# PEP 517 build is a pure-Python copy step. Identical to clean-machine-assert.sh. +# "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 { $_ }) @@ -34,8 +34,8 @@ $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 something the caller pointed at (the CI source overlay), - # never something resolution chose; index dependencies always print `==`. + # 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 diff --git a/.github/scripts/clean-machine-assert.sh b/.github/scripts/clean-machine-assert.sh index 37b9604f7c..dc528e480d 100755 --- a/.github/scripts/clean-machine-assert.sh +++ b/.github/scripts/clean-machine-assert.sh @@ -5,13 +5,12 @@ # Assert the clean-machine contract after an install attempt. # # absent The toolchain really was absent for the whole run. Catches a leg that -# "passed" only because masking silently failed, or because the installer +# "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 The wheels-only contract: 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 -# and there is nothing to read. +# 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. @@ -40,9 +39,9 @@ for check in "$@"; do 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 and survives their - # removal, 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. + # 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" @@ -74,7 +73,7 @@ for check in "$@"; 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 the question is not USE. + # 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 @@ -93,17 +92,16 @@ for check in "$@"; do ;; nobuild) - # "Built an sdist" is NOT "needed a compiler". Every name below was checked + # "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 subdirectory: 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 - # the step on Windows and macOS). + # 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 @@ -116,10 +114,10 @@ for check in "$@"; do # ` (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 something the - # caller pointed at (--local, or the editable overlay), never something - # resolution chose. Index dependencies always print `==`, so a - # genuine PyPI sdist is still caught, including one named unsloth. + # `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://" \ @@ -154,10 +152,10 @@ for check in "$@"; do # 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, including the .venv_t5_510/_530/_550 sidecars. - # 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. + # 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 @@ -165,18 +163,17 @@ for check in "$@"; do 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 executed - # 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. + # `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. + # 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" @@ -195,11 +192,11 @@ for check in "$@"; do *) bad_arch="$bad_arch $f [$desc]" ;; esac - # Signature: MAIN EXECUTABLES ONLY. Asserting it for every Mach-O failed the + # Signature: MAIN EXECUTABLES ONLY. Asserting it on every Mach-O failed the # mask/pipe leg on 29 ordinary PyPI extension modules plus libportaudio.dylib: - # those are MH_BUNDLE/MH_DYLIB images dlopen'd without library validation and - # ship unsigned, and that run had already imported them with the installer - # exiting 0. macOS enforces on main executables and gatekept .app bundles. + # 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 @@ -218,14 +215,14 @@ for check in "$@"; do # ("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 + # 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 status is what the pipeline - # returns even on a match. + # 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" ;; @@ -235,8 +232,8 @@ for check in "$@"; do fi done < <(_macho_targets | sort -u) if [ "$n" = "0" ]; then - # An empty scan reads exactly like a clean one, so the check would pass on a - # wrong root and prove nothing. + # 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 diff --git a/.github/scripts/clean-machine-env.sh b/.github/scripts/clean-machine-env.sh index 435c7f26a9..6890f2f31e 100755 --- a/.github/scripts/clean-machine-env.sh +++ b/.github/scripts/clean-machine-env.sh @@ -98,8 +98,8 @@ if [ "$MODE" = "mask" ]; then 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_link is what - # `xcode-select -p` reads, so removing it reproduces a virgin Mac's gate; + # `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 @@ -120,9 +120,9 @@ if [ "$MODE" = "mask" ]; then fi fi # Xcode.app must go too: with the link removed AND CommandLineTools moved, - # `xcode-select -p` still does not fail, it falls 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. + # `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 @@ -133,9 +133,9 @@ if [ "$MODE" = "mask" ]; then 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. Runs before the Homebrew block below so /usr/local/Homebrew is stashed - # once, with one restore line, in the right order. + # 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" @@ -175,9 +175,9 @@ if [ "$MODE" = "mask" ]; then 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. + # 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. diff --git a/.github/scripts/ensure-docker-daemon.ps1 b/.github/scripts/ensure-docker-daemon.ps1 index 8120f3f11f..50a2b5cc56 100644 --- a/.github/scripts/ensure-docker-daemon.ps1 +++ b/.github/scripts/ensure-docker-daemon.ps1 @@ -4,14 +4,12 @@ # 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 runner image (runner-images installs it -# via Microsoft's install-docker-ce.ps1, without -HyperV, so the daemon serves -# WINDOWS containers) but it is not always already RUNNING when a job starts. A -# spike run died 21 seconds in with +# 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 on a different runner was fine. Without this wait that flake -# reads as "Windows containers are not available on hosted runners", which is the -# wrong conclusion entirely. +# 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) @@ -36,8 +34,8 @@ while ($true) { 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. +# 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 index 2d7b7a6289..2c0821ca62 100644 --- a/.github/scripts/virgin-windows-install.ps1 +++ b/.github/scripts/virgin-windows-install.ps1 @@ -1,9 +1,9 @@ # 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 the -# environment has no toolchain. Runs install.ps1 the way a real user on a bare -# Windows box would, then asserts the same things the hosted Windows leg asserts. +# 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( @@ -20,16 +20,16 @@ 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 in a -# `docker exec` session, so without this the installer BLOCKS FOREVER on Read-Host -# and the job dies on timeout with no diagnosis. +# [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. Setting the install -# root explicitly also keeps the container's state entirely under one directory. +# 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". +# 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 @@ -73,8 +73,7 @@ Section 'assert: the install produced something usable' if ($rc -ne 0) { $failures += "installer exited $rc" } else { - # Mirrors the Linux leg's "Assert the install is actually usable": an installer - # that exits 0 having done nothing must not pass. + # 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 @@ -89,11 +88,10 @@ if ($rc -ne 0) { } Section 'assert: torch imports' -# On the hosted runner this proves less than it looks like: the runner 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 in -# which that is not true, so a failure here is a genuine finding about bare Windows, -# not a CI artefact. +# 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" @@ -111,9 +109,9 @@ if (Test-Path -LiteralPath $venvPy) { 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 - # therefore no App Installer, so this is the fallback path (python.org + astral.sh) - # under test -- the whole reason a container is a good harness. + # 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" @@ -132,8 +130,8 @@ if ($Overlay -and $rc -eq 0) { } Section 'assert: no non-allowlisted source build' -# Shared with the hosted Windows legs so the sdist allowlist lives in one place; the -# script prints its own diagnosis, so only the verdict is folded in here. +# 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" diff --git a/.github/scripts/virgin-windows-probe.ps1 b/.github/scripts/virgin-windows-probe.ps1 index 1993d84b96..a305d2dcae 100644 --- a/.github/scripts/virgin-windows-probe.ps1 +++ b/.github/scripts/virgin-windows-probe.ps1 @@ -1,14 +1,12 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. -# Runs INSIDE a Windows container. Proves the environment is genuinely virgin -# BEFORE anything is installed into it. -# -# This script is the entire point of the container lane. The hosted-runner Windows -# legs of clean-machine-install-ci.yml simulate absence (rename the toolcache Python -# directory, scrub the Machine and User registry PATH); this one asserts real -# absence on an OS image that never had a toolchain. If these assertions do not -# run, the lane proves nothing that the masked legs did not already prove. +# 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 = @() @@ -45,10 +43,9 @@ Write-Host "USERPROFILE : $env:USERPROFILE" Write-Host "LOCALAPPDATA : $env:LOCALAPPDATA" Write-Host "PROCESSOR_ARCH : $env:PROCESSOR_ARCHITECTURE" -# install.ps1 line 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 it. +# 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" } @@ -66,27 +63,26 @@ foreach ($t in $mustBeAbsent) { } Section 'informational: present but not a developer toolchain' -# These are 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. +# 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 absent from PATH and still be found by uv's own interpreter -# discovery or by py.exe's registry view -- that is exactly how the hosted Windows -# leg once reported `python ABSENT` and then installed with the runner's 3.13.14. -# Check the disk and the registry, not just PATH. +# 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 directories; take the first so the message names a - # real path instead of stringifying an array. + # 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)" @@ -115,12 +111,12 @@ foreach ($scope in 'Machine', 'User') { # ── The VC++ runtime question the hosted leg cannot answer ──────────────────── Section 'VC++ runtime (honest measurement)' -# clean-machine-install-ci.yml carries an explicit HONESTY NOTE that the hosted image -# ships the VC++ 2015-2022 runtime in System32 and it cannot be removed without -# breaking the runner, 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. +# 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 @@ -135,9 +131,9 @@ foreach ($k in 'HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', # ── 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. +# 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/', diff --git a/.github/workflows/clean-machine-install-ci.yml b/.github/workflows/clean-machine-install-ci.yml index 166092aa27..010bd91d25 100644 --- a/.github/workflows/clean-machine-install-ci.yml +++ b/.github/workflows/clean-machine-install-ci.yml @@ -3,42 +3,37 @@ # Proves Unsloth installs on a machine that has never seen a developer toolchain. # -# Why this exists: 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 +# 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 modes, -# answering different questions (see .github/scripts/clean-machine-env.sh): +# 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 each leg actually puts under test ──────────────────────────────────── -# install.sh / install.ps1 come from this ref, but they install unsloth FROM PyPI -- -# the consumer path, which has to stay that way -- so everything Python-side would -# come out of the RELEASED wheel (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 about the change. +# ── 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. # -# `overlay: true` legs therefore re-point the venv at this ref before studio setup -# runs, 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. Deliberately 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`, and why: -# 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 itself calls `git rev-parse` / -# `git archive` through setuptools-scm's file finder, so an -# overlay would answer the leg's own question for it. -# linux ubuntu2404-nonroot-notransport dies at the elevation gate before a venv -# exists. -# wsl only install.sh is copied into the distro; there is no -# source tree inside WSL to overlay from. +# 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 @@ -57,8 +52,8 @@ on: - 'studio/prebuilt_core.py' - 'studio/node_prebuilt_pins.json' # The overlay exists so a constraints or requirements change is exercised here - # (see the header). The update-smoke jobs cannot stand in: they start from a - # preinstalled Python and full developer tooling. + # (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' @@ -70,24 +65,19 @@ on: push: branches: [main] # Same list as the PR filter: without it a direct push to main touching any of - # these skipped the workflow, so the post-merge backstop never happened. + # 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' - # 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 falls back to a toolchain-dependent path. Left off the - # list, a change to one of them skipped the only workflow that can see it. - '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' - # 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' @@ -112,8 +102,8 @@ env: 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". + # 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: @@ -129,11 +119,11 @@ jobs: fail-fast: false matrix: include: - # `overlay` decides whether this ref's Python is put under test at all; see - # the header. The pipe legs stay on the released package on purpose. + # `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. + # 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. @@ -159,7 +149,7 @@ jobs: persist-credentials: false # No actions/setup-python on purpose: install.sh must bring its own uv-managed - # CPython, exactly as it must on a user's machine. + # CPython, as it must on a user's machine. - name: Record the pre-masking toolchain run: | @@ -169,7 +159,7 @@ jobs: 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 + # 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)" @@ -195,10 +185,10 @@ jobs: 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. + # 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 @@ -218,18 +208,18 @@ jobs: 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 and has no such hook, and overlaying this ref's Python onto it - # would report on neither honestly. + # 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. + # 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 @@ -249,22 +239,21 @@ jobs: fi case "${{ matrix.delivery }}" in file) - # Plain file execution isolates "installer logic broken" from - # "curl-pipe delivery broken". + # 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. + # 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 would pass an empty positional, - # so only add the separator when there are flags to pass. + # `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 @@ -273,27 +262,26 @@ jobs: 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 as the override. + # 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. + # 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. + # 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: | @@ -309,7 +297,7 @@ jobs: 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 afterwards. + # 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 }}' \ @@ -319,8 +307,8 @@ jobs: 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 + # 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" @@ -328,16 +316,16 @@ jobs: 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. + # 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, including a restore that genuinely broke. 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. + # `|| 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 @@ -349,8 +337,8 @@ jobs: 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. + # 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/ @@ -389,17 +377,16 @@ jobs: 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. + # 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`). The - # overlay removes that difference, exactly as it does on fedora. + # extras all fine, then `Installing triton kernels (pip) failed`). - label: ubuntu2404-nonroot image: ubuntu:24.04 runner: ubuntu-latest @@ -407,16 +394,15 @@ jobs: overlay: true nonroot: true # The same premise with the OTHER transport. install.sh's download() prefers - # curl and falls back to wget (install.sh:729-738), _http_get does the same - # for the connectivity probe (1019-1027) and the Radeon listing repeats it - # (3064-3067), and _check_linux_deps only calls the transport missing 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. Everything the row asserts is what the - # nonroot row asserts, plus curl proved absent for the whole run rather than - # merely unused. + # 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 @@ -425,9 +411,9 @@ jobs: nonroot: true wget_only: true # No elevation AND no transport. apt is the only way to get curl and reaching - # apt is what 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. + # 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 @@ -457,11 +443,11 @@ jobs: 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. + # 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 is testing install.sh's wget - # branch, and that branch is unreachable while curl is on the box. + # 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 @@ -484,7 +470,7 @@ jobs: 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 that helper. + # 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 @@ -501,8 +487,8 @@ jobs: 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. + # 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: | @@ -520,7 +506,7 @@ jobs: # 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 has no way to download anything, which is the case under test. + # view the machine can download nothing, which is the case under test. - name: Take the transport away again if: matrix.no_transport run: | @@ -538,20 +524,20 @@ jobs: 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. + # 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 is asking about. + # 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 no elevation to be had. + # 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: | @@ -562,8 +548,8 @@ jobs: exit 1 fi # Absent from disk, not merely off PATH: install.sh probes with `command -v` - # (install.sh:830), so a binary tester could not reach would still be a lie - # about the image. + # (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" @@ -575,8 +561,8 @@ jobs: 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 actually makes - # `apt-get install` impossible for tester. + # 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 @@ -584,16 +570,16 @@ jobs: 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. + # 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: | - # Absent from disk, not merely off PATH, for the same reason the sudo check - # looks on disk: `command -v` is what install.sh asks, and a binary tester - # could not reach would still be a lie about the image. + # 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" @@ -607,13 +593,13 @@ jobs: fi done # The package too, so a dependency that quietly pulled the binary back in is - # caught here rather than silently reinstating the curl branch. + # 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 does have the other one, or the row is the no-transport - # case wearing a different label. + # 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 @@ -625,9 +611,9 @@ jobs: 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 github.workspace is not - # something this needs to depend on. + # 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" @@ -639,9 +625,9 @@ jobs: echo "installer exit code: $rc" exit "$rc" - # The no-elevation case the whole 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. + # 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 }} @@ -672,8 +658,7 @@ jobs: # 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 that is not the pinned - # outcome exits 1. + # 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: | @@ -684,9 +669,8 @@ jobs: [ -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 it must be the no-sudo branch of _smart_apt_install - # (install.sh:899-903) that stopped it rather than a prompt, a dpkg lock or a - # network error. + # 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 @@ -702,8 +686,8 @@ jobs: fi echo "::notice::known outcome: no transport and no way to elevate, refused with an actionable message" - # experimental, so without this check a bootstrap outage or an unrelated early - # exit is tolerated like the intended diagnostic. + # 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: | @@ -713,15 +697,15 @@ jobs: 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. + # 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 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 + # 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. @@ -735,15 +719,15 @@ jobs: 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. + # 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 is testing what its matrix row claims. + # 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: | @@ -754,8 +738,8 @@ jobs: 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. + # 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: | @@ -767,20 +751,20 @@ jobs: - 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) + # 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. That is 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. + # 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; a source-built tree has + # 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" @@ -794,8 +778,8 @@ jobs: 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 would prove only - # that SOME install happened, which the root legs already show. + # 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: | @@ -815,16 +799,16 @@ jobs: grep -n "deps" logs/install.log | tail -20 || true exit 1 fi - # #7547 is what made the optional set stop escalating. If either prompt comes - # back, an unprivileged user is blocked on tools nothing here uses. + # #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 + # 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' @@ -839,10 +823,10 @@ jobs: 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. + # _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 @@ -871,15 +855,15 @@ jobs: # ── 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. + # 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). There is no flake to - # absorb, and #7548 is in main, so this gates unconditionally. + # 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 @@ -907,7 +891,7 @@ jobs: 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 of one. + # 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 @@ -916,10 +900,9 @@ jobs: # 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; - # running the checked-out one and reporting the leg green answered a different - # question under the same name. Empty on pull_request/push, so automatic runs - # stay on this ref. + # 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' @@ -946,15 +929,15 @@ jobs: } # 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 script and sh died on a half-read line. #7548's - # _unsloth_main wrapper makes sh parse the file first; a truncation here means - # that regressed. + # 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 does not + # 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. @@ -984,8 +967,8 @@ jobs: 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. + # 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 @@ -1022,30 +1005,29 @@ jobs: 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. + # 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 carried as a pinned known failure until #7549 landed. #7549 - # relaxed that gate to the --local and llama.cpp source paths that actually use - # git (setup.ps1:1750-1759), so the row installs end to end and gates like any - # other; the assert below is what proves it took the relaxed branch rather than - # passing because git leaked back onto PATH. + # 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. + # 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 @@ -1062,8 +1044,8 @@ jobs: 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 + + # 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\', @@ -1071,9 +1053,9 @@ jobs: '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 - # through 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 + # 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 @@ -1102,7 +1084,7 @@ jobs: # 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 with the runner's 3.13.14. + # `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 @@ -1116,10 +1098,9 @@ jobs: # 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. It is a merge, not a - # replace, so the shim above keeps resolving. Expand first: - # SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ - # (dotnet/runtime#1442). + # 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 } @@ -1140,34 +1121,34 @@ jobs: shell: pwsh run: | $leaked = @() - # `py` too: the launcher lives in C:\Windows, which the scrub keeps, and it - # finds the toolcache Python the scrub only removed from PATH. + # `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. + # 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 of one. Only a probe that STARTS counts. + # 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 check next fails it must say why. + # 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 and 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. + # 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 @@ -1184,15 +1165,15 @@ jobs: exit 1 } } elseif ('${{ matrix.os }}' -eq 'windows-latest' -and -not $winget) { - # Otherwise the visible leg quietly degrades into a second masked leg. + # 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. Be - # explicit rather than let the runner's appended `exit $LASTEXITCODE` decide. + # 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 @@ -1204,7 +1185,7 @@ jobs: 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 + # 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' @@ -1216,21 +1197,21 @@ jobs: } 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. Run 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). + # 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, because two of them are only just off a pin. + # 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: | @@ -1248,21 +1229,21 @@ jobs: Write-Host "::error::installer exited 0 but left no unsloth CLI at $cli" exit 1 } - # Present is not the same as 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. + # 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 as 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. + # 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 @@ -1275,21 +1256,20 @@ jobs: 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 is what - # proves the emulated x64 stack really resolved rather than being skipped. - # Metadata, not an import: this asserts resolution, and the import is the job of - # the torch assert below. + # 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 because git leaked back onto PATH - # and the gate was never reached. + # 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 @@ -1311,7 +1291,7 @@ jobs: } Write-Host 'no winget, no git, and the install completed anyway' - # See the macOS job: proves the leg is testing what its matrix row claims. + # 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 @@ -1329,15 +1309,15 @@ jobs: # 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 (setup.ps1:1658-1661) is the consumer - # path the visible leg exercises. The VC++ runtime is a runtime, not a toolchain. + # 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 + # 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', @@ -1346,10 +1326,10 @@ jobs: $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 already removed every - # entry matching these, so a match here means the install put one back. + # 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 } @@ -1369,31 +1349,30 @@ jobs: 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. + # 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 it - # cannot be removed 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. + # 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 question with an interpreter the install did not - # create. + # 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__)" @@ -1417,21 +1396,21 @@ jobs: 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. + # 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. + # * `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-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 @@ -1444,9 +1423,9 @@ jobs: 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". + # 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 @@ -1467,9 +1446,9 @@ jobs: - 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). + # 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 { @@ -1477,8 +1456,8 @@ jobs: 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 - # therefore its own step with its own exit code. + # 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}}')" @@ -1521,13 +1500,13 @@ jobs: 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. + # 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 that proves nothing about the change. + # setup.ps1 gets a green run proving nothing about the change. - overlay: true steps: @@ -1556,7 +1535,7 @@ jobs: 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 be reporting on an environment it never verified. + # skipped the check would report on an environment it never verified. - name: Assert the container is genuinely virgin shell: pwsh run: | @@ -1565,20 +1544,20 @@ jobs: *>&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 + # 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, and that - # host 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. + # -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 @@ -1595,16 +1574,16 @@ jobs: id: install shell: pwsh # RELEASE-LAG PIN, overlay=false only. A Server Core container has no Microsoft - # Store and therefore 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: the - # setup.ps1 inside unsloth 2026.7.5 (uploaded 2026-07-23, and #7549 landed on the - # 28th) still carries the old gate, so the row that deliberately installs from - # PyPI still cannot get past it. That is release lag, not a product gap, and - # nothing in this branch can change it; the overlay row runs the same install - # against this ref's setup.ps1 and gates unconditionally. The step below accepts - # only that exact signature and hard-errors the moment the released wheel catches - # up. + # 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 { '' } @@ -1613,31 +1592,30 @@ jobs: -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. What is added here is the part this lane alone - # can prove. + # 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 trusting the exit code alone. + # 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 would be indistinguishable from the released-wheel one. + # 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. + # 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 @@ -1646,10 +1624,10 @@ jobs: 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 Windows 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 + # 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' @@ -1659,16 +1637,16 @@ jobs: 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 actually needs VCRUNTIME140_1.dll; this is the announcement that the - # DLL got there rather than having been there all along. + # 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 that is not the pinned failure - # exits 1 and fails the (required) job. + # 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 @@ -1683,8 +1661,8 @@ jobs: } $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, and until then a - # failure anywhere else has to be red. + # 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)) { @@ -1692,14 +1670,14 @@ jobs: 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. + # 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)" @@ -1708,7 +1686,7 @@ jobs: 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 + # 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 $_ }) }) diff --git a/.github/workflows/desktop-app-clean-machine-ci.yml b/.github/workflows/desktop-app-clean-machine-ci.yml index e5746a2343..7e95aa8c26 100644 --- a/.github/workflows/desktop-app-clean-machine-ci.yml +++ b/.github/workflows/desktop-app-clean-machine-ci.yml @@ -13,15 +13,15 @@ # 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. +# 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. + # 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' @@ -50,21 +50,21 @@ concurrency: cancel-in-progress: true permissions: - # Drafts are listed only to a token with push access, and every desktop-v* release in - # this repo is a draft, so `contents: read` cannot see the bundle under test at all. + # 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. + # 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. + # 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' @@ -73,8 +73,8 @@ 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: it is a property of the trigger, not a broken release. + # 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 }} @@ -98,8 +98,8 @@ jobs: 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. + # 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 \ @@ -118,10 +118,9 @@ jobs: 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 instead. + # `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 @@ -131,8 +130,8 @@ jobs: - 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. + # 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 @@ -148,8 +147,8 @@ jobs: 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. + # 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" @@ -157,14 +156,14 @@ jobs: *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. + # 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. + # 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 @@ -179,15 +178,15 @@ jobs: 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. No change to this PR - # can move that; only a new release can. _check_macos_deps is the function - # #7547 added, so finding it means the release caught up and this pin must go. + # 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" @@ -210,8 +209,8 @@ jobs: 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. + # 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 @@ -251,18 +250,18 @@ jobs: 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. + # (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, including a restore that genuinely broke. 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. + # `|| 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 @@ -281,9 +280,7 @@ jobs: # ── Linux: .deb and .AppImage, with a real webview under Xvfb ──────────── linux: - # 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: it is a property of the trigger, not a broken release. + # 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 @@ -306,14 +303,12 @@ jobs: 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. + # 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')" - # 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 @@ -326,14 +321,14 @@ jobs: 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. + # 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. + # 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 @@ -342,9 +337,9 @@ jobs: - 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. + # 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 @@ -362,12 +357,12 @@ jobs: 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. + # 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: | @@ -379,39 +374,39 @@ jobs: 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. + # 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 is - # resolved through PATH, not the cwd, so this needs the ./ prefix. + # 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. No change to this PR - # can move that; only a new release can. _SMART_APT_OPTIONAL is the guard #7547 - # added, so finding it means the release caught up and this pin must go. + # 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, and close stdin as install.rs does. + # 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. + # 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 @@ -429,9 +424,9 @@ jobs: - 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. + # 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=$! @@ -459,20 +454,15 @@ jobs: found=1 if grep -qE "desktop_preflight completed disposition=" "$f"; then disposition=1; fi done - # Same acceptance criterion the macOS rows enforce. Everything above is - # `|| true` and the loop skips a missing log, so without these two lines the - # step could not fail. setup_logging (src-tauri/src/main.rs:50-67) opens - # tauri.log at process start, so no log means the binary never got that far, - # and the launch step only proves liveness: an app hanging before preflight - # completes would otherwise pass both Linux rows. + # 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() - # `|| true` swallowed everything, including a restore that genuinely broke. 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. + # See the macOS job: `|| true` would swallow a genuinely broken restore. run: | if [ -f .clean-machine/restore.sh ]; then bash .clean-machine/restore.sh @@ -491,9 +481,7 @@ jobs: # ── Windows: NSIS setup.exe, silent install ────────────────────────────── windows: - # 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: it is a property of the trigger, not a broken release. + # 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 @@ -512,14 +500,12 @@ jobs: 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. + # 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')" - # 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 @@ -531,24 +517,21 @@ jobs: ls -la dl - name: Strip the developer toolchain - # `inputs` exists only for workflow_dispatch, so on pull_request and schedule - # `inputs.strip_toolchain` is '' -- and loose equality coerces both '' and - # false to 0, making `!= false` FALSE, so automatic runs would keep the hosted - # toolchain this workflow exists to remove. Gate on the event instead. + # 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 + # 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 the no-winget - # fallback that clean-machine-install-ci.yml already covers and pins on its - # winget=masked row, and it does so as a hard failure. Resolve winget before - # the scrub and hand it back through a shim, exactly as that workflow does. + # 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' @@ -564,9 +547,8 @@ jobs: } "PATH=$shim;$((& $scrub ($env:PATH -split ';')) -join ';')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - # 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. + # 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 @@ -574,13 +556,12 @@ jobs: 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 + # 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) @@ -599,11 +580,11 @@ jobs: 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. + # 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: | @@ -615,8 +596,8 @@ jobs: } # `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 just the launcher's REGISTRY view, which still names - # the paths the rename removed, so a start attempt is the only real evidence. + # `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 @@ -624,16 +605,15 @@ jobs: 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 with nothing printed on a - # machine that is in fact clean. + # 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 it, or this lane + # $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' })) @@ -651,8 +631,8 @@ jobs: 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. + # /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 } @@ -666,8 +646,8 @@ jobs: - 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, + # 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. @@ -697,8 +677,8 @@ jobs: 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. + # 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' @@ -738,11 +718,8 @@ jobs: -SimpleMatch -Quiet) { $disposition = $true } } } - # Same acceptance criterion macOS and Linux enforce. Test-Path, Get-Content and + # 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. - # 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 an app that - # hangs before preflight would otherwise pass. if (-not $found) { Write-Host '::error::the app wrote no tauri.log; it never reached setup_logging' exit 1 diff --git a/install.ps1 b/install.ps1 index a26de28a19..11de4aec5d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -2766,18 +2766,17 @@ exit 0 # 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 it installs - # unsloth from PyPI, the consumer path, so everything Python-side comes out - # of the released wheel (studio/setup.ps1, install_python_stack.py and every - # requirements/constraints file they reach via Path(__file__)) and the - # workflow meant to validate a branch could not. `& $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 the branch unchanged. 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 nothing and clones nothing, so it survives - # git, cmake and MSVC all missing. + # 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"))) { @@ -2785,8 +2784,8 @@ exit 0 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 downloads its pinned build backend from PyPI, - # so it carries the same transient-network risk as every other step. + # 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) diff --git a/install.sh b/install.sh index 28975a4060..d474a6becf 100755 --- a/install.sh +++ b/install.sh @@ -4189,16 +4189,15 @@ fi # 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 it installs unsloth -# from PyPI, the consumer path. Everything Python-side then comes out of the -# released wheel (studio/setup.sh, setup.ps1, install_python_stack.py and every -# requirements/constraints file they reach via Path(__file__)), so the workflow -# meant to validate a branch could not. An editable overlay re-points -# `import studio` at the working tree, and the importlib.resources lookup below -# then finds the branch's setup.sh unchanged. 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 nothing and clones nothing, so it survives git, cmake and the C/C++ +# 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 @@ -4206,8 +4205,8 @@ if [ -n "${UNSLOTH_CI_SOURCE_OVERLAY:-}" ]; then exit 1 fi substep "CI: overlaying source checkout (editable, no deps): $UNSLOTH_CI_SOURCE_OVERLAY" - # Retry: the editable build downloads its pinned build backend from PyPI, so - # it carries the same transient-network risk as every other install step. + # 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 diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index c3b80cdee3..bac5773625 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -2790,8 +2790,7 @@ def pip_install_try( env = _install_env_for_cmd(cmd), ) if result.returncode == 0: - # Same reasoning as pip_install below: `nobuild` can only catch a source - # build that reaches the log. + # 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 @@ -2849,14 +2848,13 @@ def pip_install( **_windows_hidden_subprocess_kwargs(), ) if result.returncode == 0: - # Echo successful output under UNSLOTH_VERBOSE, as install.sh's + # Echo success under UNSLOTH_VERBOSE, as install.sh's # run_install_cmd does. Without it the dependency phase never - # reached the install log, and clean-machine-assert.sh's `nobuild` - # greps that log for uv's "Building ==" -- so a source - # build in this step, the one installing studio.txt where an - # sdist-only dependency actually shows up, reported "built: none" - # and the leg stayed green. Redacted: uv echoes index URLs with - # credentials. + # 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