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