diff --git a/.github/scripts/clean-machine-assert.sh b/.github/scripts/clean-machine-assert.sh index 22b29e090a..d08ba5764b 100755 --- a/.github/scripts/clean-machine-assert.sh +++ b/.github/scripts/clean-machine-assert.sh @@ -4,14 +4,14 @@ # # Assert the clean-machine contract after an install attempt. # -# absent The toolchain really was absent for the whole run. Guards against a leg -# that "passed" only because masking silently failed, or because the -# installer quietly installed Xcode CLT behind our back. +# absent The toolchain really was absent for the whole run. Catches a leg that +# "passed" only 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, else -# run_install_cmd (install.sh:193-243) discards the uv output on success -# and there is nothing here to read. +# "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. # # Usage: bash .github/scripts/clean-machine-assert.sh absent notools nobuild set -uo pipefail @@ -28,8 +28,8 @@ for check in "$@"; do absent) # Deliberately NOT `command -v`: on a virgin Mac /usr/bin/{git,cc} EXIST as CLT - # stubs, so `command -v` succeeds and only RUNNING them fails ("xcrun: error: - # invalid active developer path"). The honest invariant is: must not WORK. + # stubs, so `command -v` succeeds and only RUNNING them fails ("invalid active + # developer path"). The honest invariant is: must not WORK. if xcode-select -p >/dev/null 2>&1; then fail "xcode-select -p still resolves to $(xcode-select -p 2>/dev/null); not a clean Mac" else @@ -38,10 +38,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 keeps working once - # the CLT are gone, so no masking can remove it. cc and clang do become - # stubs and the macOS consumer path needs no git, so report rather than - # call the simulation broken. + # 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. case " ${UNSLOTH_CLEAN_ALLOW_WORKING:-} " in *" $tool "*) echo "[assert] NOTE $tool still works ($(command -v "$tool")); allowed on this runner" @@ -73,10 +72,9 @@ for check in "$@"; do [ -n "$tool" ] || continue case " $allow " in *" $tool "*) continue ;; esac # `xcode-select -p` only ASKS whether a toolchain is selected; the installer - # has to ask, and the point of the fix is that it carries on without one. - # Counting the question as toolchain USE would fail the very leg that proves - # the toolchain was never used. `--install`, which pops the CLT installer, - # stays a hit. + # has to ask, and the fix is that it carries on without one. Counting the + # question as USE would fail the very leg proving the toolchain went + # untouched. `--install`, which pops the CLT installer, stays a hit. if [ "$tool" = "xcode-select" ]; then case "$rest" in -p|--print-path|-v|--version|"") continue ;; @@ -94,44 +92,38 @@ for check in "$@"; do ;; nobuild) - # "Built an sdist" is NOT "needed a compiler". Every name below was checked - # against its actual sdist: setuptools.build_meta backend, no ext_modules, - # and not one .c/.cpp/.pyx/.rs file in the archive, so the PEP 517 build is - # a pure-Python metadata-and-copy step that completes with no compiler. + # "Built an sdist" is NOT "needed a compiler", so the contract is "nothing + # needing a COMPILER was built". Every name below was checked against its + # actual sdist: setuptools.build_meta backend, no ext_modules, not one + # .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 -- studio/backend/requirements/ - # triton-kernels.txt pins it to a git URL under the triton repo's - # python/triton_kernels subdirectory. That tree is 75 files of Python - # with a four-line pyproject.toml and no setup.py; the kernels are - # Triton DSL compiled at runtime, never at install time. It is also 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). - # Failing on those is a false alarm, so the contract is "nothing needing a - # COMPILER was built". UNSLOTH_ALLOW_SDIST extends the allowlist. + # 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). + # UNSLOTH_ALLOW_SDIST extends the allowlist. # - # Lowercased and underscore-folded on both sides, because a project's - # distribution name and the name uv prints can disagree on the separator: - # the requirement says triton_kernels, the build line says triton-kernels, - # and an allowlist that matched only one spelling would silently miss. + # Lowercased and underscore-folded on both sides: a distribution name and the + # name uv prints can disagree on the separator (requirement triton_kernels vs + # build line triton-kernels), and a one-spelling allowlist silently misses. _allow="$(printf '%s' "openai-whisper argbind randomname antlr4-python3-runtime triton-kernels ${UNSLOTH_ALLOW_SDIST:-}" | tr 'A-Z_' 'a-z-')" if [ ! -f "$LOG" ]; then fail "nobuild requested but $LOG is missing" else # uv does NOT use pip's phrasing: it prints `Building ==` to # stderr (astral-sh/uv#11165), so the pip-only pattern left _built empty on - # every uv source build. Match both spellings. Requiring `==` or ` @ ` after - # the name keeps this off the installer's own lowercase "building frontend..." + # every uv source build. Match both. Requiring `==` or ` @ ` after the name + # keeps this off the installer's own lowercase "building frontend..." # progress text. Strip ANSI first so a coloured run (FORCE_COLOR) parses. # - # `Building @ file://...` is dropped before the names are read: a - # local-path build is something the caller pointed at (install.sh --local, - # or the UNSLOTH_CI_SOURCE_OVERLAY editable overlay the CI legs use to put - # the branch's Python code under test), never a dependency that resolution - # chose. Dependencies from an index always print `==`, so - # this drops no real signal -- a genuine sdist pulled from PyPI is still - # caught, including one named unsloth. + # `Building @ file://...` is dropped first: a local-path build is + # something the caller pointed at (install.sh --local, or the + # UNSLOTH_CI_SOURCE_OVERLAY editable overlay), never a dependency resolution + # chose. Index dependencies always print `==`, so no signal is + # lost: a genuine sdist from PyPI 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://" \ diff --git a/.github/scripts/clean-machine-env.sh b/.github/scripts/clean-machine-env.sh index 1ed51bbac9..138ba922d0 100755 --- a/.github/scripts/clean-machine-env.sh +++ b/.github/scripts/clean-machine-env.sh @@ -3,16 +3,16 @@ # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. # # Simulate a virgin developer machine on a GitHub-hosted runner. Two modes, because -# "the tool is absent" and "the installer never called the tool" cannot be simulated -# by the same mechanism: +# "the tool is absent" and "the installer never called the tool" need different +# mechanisms: # # mask Make the toolchain genuinely ABSENT: scrub PATH to OS defaults and (with -# --remove) move the real toolchain aside, so `command -v git` correctly -# FAILS, as on a clean Mac. A failing "poison shim" would do the opposite -- -# `command -v` finds it and reports the tool as present -- so no shims here. -# trace Leave the toolchain working but route it through logging wrappers that log -# the call then exec the real binary, proving whether the installer ever -# REACHES for a compiler/git without changing behaviour. +# --remove) move the real toolchain aside so `command -v git` correctly +# FAILS. Deliberately no "poison shims": a failing shim is still FOUND by +# `command -v`, which reports the tool as present, the opposite of clean. +# trace Leave the toolchain working behind wrappers that log the call then exec +# the real binary, answering whether the installer ever REACHES for a +# compiler/git without changing behaviour. # # Writes shell exports to $CLEAN_ENV_FILE (default ./clean-machine.env) to `source`; # nothing is exported globally, so other steps keep a normal environment. @@ -81,10 +81,10 @@ if [ "$MODE" = "mask" ]; then } >> "$ENV_FILE" if [ "$REMOVE" = "1" ] && [ "$OS" = "Darwin" ]; then - # Best-effort real removal; each step is independent and recorded in restore.sh - # so an `if: always()` step can put the runner back. xcode_select_link is exactly - # what `xcode-select -p` reads, so removing it reproduces a virgin Mac's gate. - # `xcode-select --reset` is NOT enough: it can reselect a full Xcode.app. + # 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; + # `xcode-select --reset` is NOT enough, it can reselect a full Xcode.app. if [ -e /var/db/xcode_select_link ]; then if sudo rm -f /var/db/xcode_select_link 2>/dev/null; then note "removed /var/db/xcode_select_link" @@ -106,8 +106,7 @@ if [ "$MODE" = "mask" ]; then # 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} and silently un-cleans the machine. A rename is - # instant regardless of bundle size: same filesystem, no copy. + # 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 @@ -128,6 +127,28 @@ if [ "$MODE" = "mask" ]; then fi done fi + + if [ "$REMOVE" = "1" ] && [ "$OS" = "Linux" ]; then + # A hosted Linux runner keeps git, gcc, cmake and make in /usr/bin, which the PATH + # scrub has to keep, so absence must be made real: move the resolved binaries + # aside (recorded in restore.sh). Versioned siblings like gcc-11 survive, but a + # consumer install invokes the unsuffixed names, which is what `absent` checks. + for tool in $TOOLS; do + # Repeat per tool: a runner can carry the same name in /usr/bin and + # /usr/local/bin, and moving only the first leaves the second on PATH. + for _ in 1 2 3 4; do + real="$(command -v "$tool" 2>/dev/null || true)" + [ -n "$real" ] && [ -e "$real" ] || break + if sudo mv "$real" "$real.masked" 2>/dev/null; then + note "moved $real aside" + echo "sudo mv '$real.masked' '$real' 2>/dev/null || true" >> "$RESTORE" + else + note "WARN could not move $real" + break + fi + done + done + fi fi # ── trace ───────────────────────────────────────────────────────────────────── diff --git a/.github/workflows/clean-machine-install-ci.yml b/.github/workflows/clean-machine-install-ci.yml index a2ddcdce62..b000b3380a 100644 --- a/.github/workflows/clean-machine-install-ci.yml +++ b/.github/workflows/clean-machine-install-ci.yml @@ -16,33 +16,31 @@ # # ── What each leg actually puts under test ──────────────────────────────────── # install.sh / install.ps1 come from this ref, but they install unsloth FROM PyPI, -# because that is the consumer path and it has to stay that way. Everything -# Python-side is therefore read out of the RELEASED wheel: studio/setup.sh, -# studio/setup.ps1, studio/install_python_stack.py, and every requirements and -# constraints file those resolve through Path(__file__). Left alone, this workflow -# can only ever validate the two shell installers, and a branch that changes -# constraints.txt or setup.ps1 gets a green run that proves nothing about the -# change. The `Assert the Fedora outcome is a known one` step below was already -# carrying a hand-written workaround for exactly this. +# the consumer path, which has to stay that way. Everything Python-side is therefore +# read out of the RELEASED wheel: studio/setup.sh, setup.ps1, +# install_python_stack.py, and every requirements and constraints file those reach +# through Path(__file__). Left alone this workflow validates only the two shell +# installers, and a branch changing constraints.txt or setup.ps1 gets a green run +# that proves nothing about the change; the `Assert the Fedora outcome is a known +# one` step below was already working around exactly that. # -# So legs with `overlay: true` re-point the venv at this ref before studio setup -# runs, via UNSLOTH_CI_SOURCE_OVERLAY (install.sh / install.ps1, just above their -# "Run studio setup" section): a `--no-deps` editable install of the checkout. -# That makes `import studio` resolve to the working tree, so the existing -# setup-script lookup finds this ref's setup.sh / setup.ps1 and install_python_stack -# reads this ref's constraints. It is deliberately NOT `install.sh --local`: -# --local also installs `unsloth-zoo @ git+https://...`, which genuinely needs git, -# and git absence is the whole point of the masked legs. The overlay resolves no -# dependencies and clones nothing, so it still works with git, cmake and the -# compilers all gone. +# So `overlay: true` legs re-point the venv at this ref before studio setup runs, via +# UNSLOTH_CI_SOURCE_OVERLAY (install.sh / install.ps1, just above their "Run studio +# setup" section): a `--no-deps` editable install of the checkout. `import studio` +# then resolves to the working tree, so the existing setup-script lookup finds this +# ref's setup.sh / setup.ps1 and install_python_stack reads this ref's constraints. +# Deliberately NOT `install.sh --local`: that also installs +# `unsloth-zoo @ git+https://...`, which genuinely needs git, and git absence is the +# whole point of the masked legs. The overlay resolves nothing and clones nothing, so +# it survives git, cmake and the compilers all being gone. # # 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. An -# overlay here would answer the leg's own question for it. +# `git archive` through setuptools-scm's file finder, so an +# overlay would answer the leg's own question for it. # linux ubuntu2404-nonroot 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. @@ -59,18 +57,18 @@ on: - 'studio/install_python_stack.py' # The overlay exists so a constraints or requirements change is actually # exercised here (see the header). Without these paths the one workflow that - # resolves them on a machine with no compiler and no wheels cached never runs - # for the PR that changes them, and the update-smoke jobs cannot stand in: - # they start from a preinstalled Python and full developer tooling. + # resolves them with no compiler and no cached wheels never runs for the PR that + # changes them, and the update-smoke jobs cannot stand in: they start from a + # preinstalled Python and full developer tooling. - 'studio/backend/requirements/**' - '.github/scripts/clean-machine-*.sh' - '.github/workflows/clean-machine-install-ci.yml' push: branches: [main] - # Same list as the PR filter. A direct push to main that changed setup.sh, - # setup.ps1, install_python_stack.py, a requirements file or a clean-machine - # helper skipped this workflow entirely, so the post-merge run that is supposed - # to be the backstop for exactly those files never happened. + # Same list as the PR filter. A direct push to main touching setup.sh, setup.ps1, + # install_python_stack.py, a requirements file or a clean-machine helper skipped + # this workflow entirely, so the post-merge backstop for exactly those files never + # happened. paths: - 'install.sh' - 'install.ps1' @@ -100,9 +98,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 the `nobuild` assertion can only - # ever 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: @@ -112,28 +109,28 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 40 continue-on-error: ${{ matrix.experimental }} - # Explicit legs, not a full cross-product: the interesting dimensions are - # (does the toolchain exist) x (how the script is delivered), not every pairing. + # Explicit legs, not a full cross-product: the interesting dimensions are (does + # the toolchain exist) x (how the script is delivered), not every pairing. strategy: fail-fast: false matrix: include: - # `overlay` decides whether this ref's Python code 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 put 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. Default install (with - # torch) because that is what a consumer actually 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. - {os: macos-14, mode: mask, delivery: tauri, flags: '', experimental: false, overlay: true} - # Toolchain present but logged: does the installer ever reach for it? - # No overlay: the editable build calls git itself (setuptools-scm), which - # would plant the very evidence `notools` exists to look for. + # Toolchain present but logged: does the installer ever reach for it? No + # overlay: the editable build calls git itself (setuptools-scm), planting the + # very evidence `notools` looks for. - {os: macos-14, mode: trace, delivery: file, flags: '', experimental: false, overlay: false} # --no-torch is the one macOS path that can still want a compiler - # (sentencepiece has no guaranteed cp313 arm64 wheel), so probe it apart - # from the default path instead of letting it hide the gate under test. + # (sentencepiece has no guaranteed cp313 arm64 wheel), so probe it apart from + # the default path rather than let it hide the gate under test. - {os: macos-14, mode: mask, delivery: file, flags: '--no-torch', experimental: true, overlay: true} - {os: macos-15, mode: mask, delivery: pipe, flags: '', experimental: false, overlay: false} - {os: macos-26, mode: mask, delivery: file, flags: '', experimental: true, overlay: true} @@ -179,11 +176,11 @@ jobs: - name: Verify the trace actually records if: matrix.mode == 'trace' run: | - # `notools` reads an absence, so a shim dir that never reached PATH is - # indistinguishable from an installer that touched nothing, and the one leg - # carrying that assertion would pass no matter what the installer did. - # Prove the wrapper records before trusting an empty file. macOS never - # probes git off the --local path, so this must be an explicit call. + # `notools` reads an absence, so a shim dir that never reached PATH looks + # exactly like an installer that touched nothing, and the one leg carrying + # that assertion would pass whatever the installer did. Prove the wrapper + # records before trusting an empty file. macOS never probes git off the + # --local path, so the call has to be explicit. set -a; . ./clean-machine.env; set +a [ -n "$UNSLOTH_TOOL_TRACE" ] || { echo "::error::trace mode set no UNSLOTH_TOOL_TRACE"; exit 1; } git --version >/dev/null 2>&1 || true @@ -201,28 +198,41 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Withheld on PR: this step runs checked-out PR code. HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }} - # Empty, and therefore ignored by install.sh, on the non-overlay legs. - # Also empty for `installer_source: published`, where the script under test - # is production's and has no such hook: overlaying this ref's Python onto it - # would report on neither one honestly. + # 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. 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 `published` dispatch asks whether unsloth.ai's script works. Only `pipe` + # honoured it, so six of the eight macOS rows ran the checked-out script and + # were still reported as published coverage. Resolve it once, here, for every + # delivery. Empty on pull_request/push, so automatic runs stay on this ref. + SCRIPT=install.sh + if [ "${{ inputs.installer_source }}" = "published" ]; then + curl -fsSL https://unsloth.ai/install.sh -o published-install.sh + SCRIPT=published-install.sh + echo "installer: published (unsloth.ai)" + else + echo "installer: this ref ($GITHUB_SHA)" + fi case "${{ matrix.delivery }}" in file) # Plain file execution isolates "installer logic broken" from # "curl-pipe delivery broken". - bash install.sh $FLAGS 2>&1 | tee logs/install.log || rc=$? + 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. This input is empty on - # pull_request/push, so only an explicit dispatch tests unsloth.ai. + # 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 | sh -s -- $FLAGS 2>&1 | tee logs/install.log || rc=$? else @@ -243,7 +253,7 @@ jobs: # before doing any work. The runner is ephemeral, so the real home is # as disposable as the override. env -u UNSLOTH_STUDIO_HOME \ - bash install.sh --tauri $FLAGS < /dev/null 2>&1 | tee logs/install.log || rc=$? + bash "$SCRIPT" --tauri $FLAGS < /dev/null 2>&1 | tee logs/install.log || rc=$? ;; esac echo "install_rc=$rc" >> "$GITHUB_OUTPUT" @@ -255,9 +265,9 @@ jobs: fi exit "$rc" - # Without this the gap comes back silently: install.sh ignores an unset - # UNSLOTH_CI_SOURCE_OVERLAY, so a typo in the matrix or the expression would - # put every leg back on the released wheel and nothing in the run would say so. + # Without this the gap returns silently: install.sh ignores an unset + # UNSLOTH_CI_SOURCE_OVERLAY, so a typo in the matrix or the expression puts every + # leg back on the released wheel with nothing in the run saying so. - name: Assert this ref's Python was really put under test if: matrix.overlay && inputs.installer_source != 'published' && steps.install.outcome == 'success' run: | @@ -272,10 +282,10 @@ jobs: run: | set -a; . ./clean-machine.env; set +a checks="nobuild" - # `absent` ran only BEFORE the install, so an installer that quietly - # selected the CLT or installed a compiler left the leg green while every - # later source build could succeed -- the exact behaviour the assert script - # says `absent` guards the whole run against. Re-run it after the install. + # `absent` ran only BEFORE the install, so an installer that quietly selected + # the CLT or installed a compiler left the leg green while every later source + # build could succeed, the exact behaviour `absent` claims to guard the whole + # run against. Re-run it afterwards. [ "${{ matrix.mode }}" = "mask" ] && checks="$checks absent" [ "${{ matrix.mode }}" = "trace" ] && checks="$checks notools" UNSLOTH_CLEAN_ALLOW_WORKING='${{ matrix.allow_working }}' \ @@ -285,9 +295,9 @@ jobs: if: steps.install.outcome == 'success' run: | set -a; . ./clean-machine.env; set +a - # The tauri leg cannot honour UNSLOTH_STUDIO_HOME (see Install), so it went - # to the legacy root: llama.cpp sits at /llama.cpp and the venv at - # /studio, so this is ~/.unsloth, not ~/.unsloth/studio. + # The tauri leg cannot honour UNSLOTH_STUDIO_HOME (see Install) and went to + # the legacy root, where llama.cpp sits at /llama.cpp and the venv at + # /studio: so ~/.unsloth, not ~/.unsloth/studio. if [ "${{ matrix.delivery }}" = "tauri" ]; then HOME_DIR="$HOME/.unsloth" else @@ -303,8 +313,8 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - # Two matrix 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/ @@ -321,8 +331,8 @@ jobs: container: ${{ matrix.image }} timeout-minutes: 40 continue-on-error: ${{ matrix.experimental }} - # Container jobs default to `sh -e` (dash), where `set -o pipefail` is an - # "Illegal option" that kills the step before the installer even starts. + # Container jobs default to `sh -e` (dash), where `set -o pipefail` is an "Illegal + # option" that kills the step before the installer starts. defaults: run: shell: bash @@ -330,8 +340,8 @@ jobs: fail-fast: false matrix: include: - # Root + apt available: install.sh's _smart_apt_install should self-heal - # from a base image with no curl, git, gcc or cmake at all. + # Root + apt: install.sh's _smart_apt_install should self-heal from a base + # image with no curl, git, gcc or cmake at all. - label: ubuntu2404-root image: ubuntu:24.04 runner: ubuntu-latest @@ -342,17 +352,16 @@ jobs: runner: ubuntu-24.04-arm experimental: false overlay: true - # No elevation: today this hard-fails at install.sh:856-861. Expected - # failure; the point is to pin the message and prove it is actionable - # rather than a bare `curl: (56)`. No overlay: it never gets as far as a - # venv, so there would be nothing to overlay into. + # No elevation: today this hard-fails at install.sh:856-861. Expected; the + # point is to pin the 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 image: ubuntu:24.04 runner: ubuntu-latest experimental: true overlay: false - # Non-apt: today this hard-fails at install.sh:2034. Expected failure; - # forces the decision on whether dnf/pacman/zypper get supported. + # Non-apt: today this hard-fails at install.sh:2034. Expected; forces the + # decision on whether dnf/pacman/zypper get supported. - label: fedora41 image: fedora:41 runner: ubuntu-latest @@ -366,16 +375,15 @@ jobs: printf '%-8s %s\n' "$t" "$(command -v $t 2>/dev/null || echo ABSENT)" done | tee /tmp/container-baseline.txt - # The advertised `curl | sh` cannot even start on an image without curl, so the + # The advertised `curl | sh` cannot start on an image without curl, so the # transport is provisioned apart from the installer's own dependencies. # Everything else stays absent. - name: Provision only the bootstrap transport run: | # tar and gzip ride along on the overlay legs: with no actions/checkout here - # (it needs git) the only way to get this ref's source into the container is - # to fetch and unpack an archive over the same transport. Neither is a - # compiler, git or cmake, so the clean-machine premise is untouched. Both - # are usually already in the base image; naming them just makes it certain. + # (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. pkgs="ca-certificates curl" if [ "${{ matrix.overlay }}" = "true" ]; then pkgs="$pkgs tar gzip"; fi if command -v apt-get >/dev/null 2>&1; then @@ -386,8 +394,8 @@ jobs: # No actions/checkout on purpose: it needs git, and a container with git # preinstalled is not the clean machine under test. Fetch over the transport - # above, and fetch the INSTALLER from the same ref so these legs can validate a - # fix instead of only the published script. + # above, INSTALLER included, so these legs validate a fix and not just the + # published script. - name: Fetch installer + assert script for this ref run: | mkdir -p logs .github/scripts @@ -403,9 +411,9 @@ jobs: fi wc -l install.sh - # The overlay needs a source tree, and these legs deliberately have no + # 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 code gets in without a git client. + # 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: | @@ -420,11 +428,11 @@ jobs: if: matrix.label == 'ubuntu2404-nonroot' run: | useradd -m tester - # Switching user without a login shell keeps the caller's environment, so - # the workflow-wide UNSLOTH_STUDIO_HOME follows tester in, and install.sh + # 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 (503-559), long - # before the elevation gate (840-861). Without a writable target this leg - # dies on "cannot be created" instead of on "cannot elevate". + # before the elevation gate (840-861). Without a writable target this leg dies + # on "cannot be created" rather than "cannot elevate". mkdir -p "$UNSLOTH_STUDIO_HOME" # No sudo installed and not root -> exercises the "cannot elevate" branch. chown -R tester logs install.sh "$UNSLOTH_STUDIO_HOME" @@ -434,16 +442,16 @@ jobs: if: matrix.label != 'ubuntu2404-nonroot' run: | set -o pipefail - # Resolved here rather than 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 github.workspace is not + # something this needs to depend on. if [ -d ci-source ]; then export UNSLOTH_CI_SOURCE_OVERLAY="$PWD/ci-source" echo "overlaying this ref's source from $UNSLOTH_CI_SOURCE_OVERLAY" fi rc=0 - # Piped: the advertised command, and the shape that turns an early exit - # into curl:(56). + # Piped: the advertised command, and the shape that turns an early exit into + # curl:(56). cat install.sh | sh 2>&1 | tee logs/install.log || rc=$? echo "installer exit code: $rc" exit "$rc" @@ -456,16 +464,15 @@ jobs: su tester -c 'cat install.sh | sh' > logs/install.log 2>&1 || rc=$? echo "installer exit code: $rc" tail -40 logs/install.log - # It may legitimately fail; what must NOT happen is an unexplained exit or - # a bare broken-pipe error standing in for a real diagnosis. + # It may legitimately fail; what must NOT happen is an unexplained exit or a + # bare broken-pipe error standing in for a real diagnosis. if [ "$rc" != "0" ] && ! grep -qiE "sudo is not available|apt-get install|missing:|permission" logs/install.log; then echo "::error::unprivileged install failed with no actionable message" exit 1 fi - # This leg is continue-on-error like the nonroot one, so without the same check - # a bootstrap outage or an unrelated early exit would be tolerated exactly like - # the intentional diagnostic. + # continue-on-error like the nonroot leg, so without the same check 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: | @@ -478,30 +485,28 @@ jobs: # install.sh comes from this ref, so which of the two accepted outcomes # applies depends on which dependency gate this ref carries. if grep -q "using prebuilt llama.cpp (missing:" logs/install.log; then - # The gate no longer hard-stops on a non-apt distro: it warns that the - # optional build tools are absent and carries on. Reaching this warning is - # what proves the Linux gate did not stop the install. - # Past that point the accepted failure used to be release lag: install.sh - # came from this ref but unsloth from PyPI, and the released - # studio/install_python_stack.py has no "skip the triton kernels when git - # is missing" guard, so it fetched the git+https triton_kernels - # requirement on a machine with no git. This leg is now overlaid with this - # ref's Python (see the header), so that guard is this ref's own code and - # the triton failure must NOT come back. Accepting it here would be - # accepting a regression in the guard as if it were release lag. + # 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 + # triton_kernels requirement with no git. The overlay makes that guard this + # ref's own code, so the triton failure must NOT come back: accepting it + # would be accepting a regression in the guard as release lag. if grep -q "Installing triton kernels (pip) failed" logs/install.log; then echo "::error::triton kernels still failed with this ref's install_python_stack.py overlaid, so its no-git skip did not hold" exit 1 fi # Nothing past the dependency warning is acceptable any more: the one # tolerated failure was the released package lagging this ref, and the - # overlay removes that difference. A failure here is this ref's own. + # overlay removes that difference. echo "::error::fedora got past the dependency warning and still failed, with this ref's Python overlaid; there is no known-good outcome left to accept" exit 1 fi # This ref still hard-exits on a non-apt package manager. Pin that message so - # a bootstrap outage or an unrelated early exit is not tolerated as if it - # were the intentional diagnostic. + # 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 @@ -518,8 +523,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. These required Linux rows had no check that the install - # produced anything runnable, unlike the WSL and Windows jobs. + # 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' run: | @@ -555,8 +560,8 @@ jobs: # environment, which cannot catch anything about a real WSL. # # No third-party action: the official Ubuntu rootfs plus `wsl --import` is - # deterministic and checksum-verifiable, and adds no supply-chain dependency to a - # repo that audits its lockfiles. + # deterministic and checksum-verifiable, adding no supply-chain dependency to a repo + # that audits its lockfiles. wsl: name: wsl ubuntu-24.04 runs-on: windows-latest @@ -584,20 +589,20 @@ 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. - # That is the clean machine, not a simulation of one. + # A freshly imported rootfs is genuinely bare: no curl, git or compiler. The + # clean machine, not a simulation of one. wsl -d unsloth-ci -- sh -c 'for t in curl wget git gcc cmake python3 sudo; do printf "%-8s %s\n" "$t" "$(command -v $t || echo ABSENT)"; done' - name: Install inside WSL, piped exactly as documented shell: pwsh run: | - # Only ca-certificates + curl: the advertised one-liner cannot start without - # a transport. Everything else must come from the installer. + # 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 that selects `published` is asking whether the script on - # unsloth.ai works; running the checked-out one here 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; + # 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. 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' @@ -606,22 +611,21 @@ jobs: exit 1 } } else { - # Copy the script in rather than reaching across /mnt/c: a DrvFs path - # brings Windows permissions and CRLF risk, neither of which a real WSL - # user has. + # Copy the script in rather than reach across /mnt/c: a DrvFs path brings + # Windows permissions and CRLF risk, neither of which a real WSL user has. $wslPath = (wsl -d unsloth-ci -- wslpath -a "$($env:GITHUB_WORKSPACE -replace '\\','/')/install.sh").Trim() Write-Host "installer source in WSL: $wslPath" wsl -d unsloth-ci -u root -- cp "$wslPath" /root/install.sh } # Piped, same shape as `curl ... | sh`, so an early exit still exposes the - # broken pipe, but the script under test is this ref not production's. + # broken pipe, but on the script this dispatch selected. wsl -d unsloth-ci -u root -- sh -c 'cd /root && cat install.sh | sh' 2>&1 | Tee-Object -FilePath logs/wsl-install.log $installRc = $LASTEXITCODE Write-Host "installer exit: $installRc" - # Printing the code discarded it. The CLI check in the next step does not - # 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 and the whole job looked green. + # Printing the code discarded it, and the next step's CLI check does not + # 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 and the job looked green. if ($installRc -ne 0) { Write-Host "::error::WSL installer exited $installRc" exit $installRc @@ -634,11 +638,10 @@ jobs: # The platform line proves the wsl branch was taken rather than plain linux. Select-String -Path logs/wsl-install.log -Pattern 'platform|\[TAURI:DIAG\]|wsl' -ErrorAction SilentlyContinue | Select-Object -First 10 - # Printing could not fail, and that alternation also matches - # `platform linux`: if detection regresses, every WSL branch is skipped and - # this job still passes as a plain-Linux install, the one thing no other job - # covers. `step` writes the label in reverse video, so strip ANSI first or - # an anchored match can never hit. + # Printing could not fail, and that alternation also matches `platform linux`: + # if detection regresses, every WSL branch is skipped and this job still passes + # as a plain-Linux install, the one thing no other job covers. `step` writes + # the label in reverse video, so strip ANSI or an anchored match never hits. $esc = [char]27 $platformLines = @( Get-Content logs/wsl-install.log -ErrorAction SilentlyContinue | @@ -691,33 +694,31 @@ jobs: 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. + # Ensure-VCRedist silently does not run, leaving torch unable to load, hence + # the explicit `import torch` assert below. # - # This leg fails at studio/setup.ps1:1652-1670, the unconditional - # "Git is required but could not be installed automatically" gate: no winget - # means no way to fetch git, and setup.ps1 refuses to continue without it. - # Before the overlay that failure came out of the RELEASED setup.ps1 and said - # nothing about this ref. It now comes out of this ref's own copy, which - # still carries the same gate, so the leg is red for a real and currently - # unfixed reason rather than for an untestable one. Relaxing that gate to - # `--local` and llama.cpp source builds is what turns it green, and this - # overlay is what will let this workflow see it happen. + # It fails at studio/setup.ps1:1652-1670, the unconditional "Git is required + # but could not be installed automatically" gate: no winget means no way to + # fetch git. Before the overlay that failure came out of the RELEASED setup.ps1 + # and said nothing about this ref; it now comes out of this ref's own copy, + # which carries the same gate, so the leg is red for a real and currently + # unfixed reason rather than an untestable one. Relaxing that gate to --local + # and llama.cpp source builds turns it green, and the overlay is what lets this + # workflow see that happen. - os: windows-latest winget: 'masked' experimental: false overlay: true - # Windows on ARM gets as far as the dependency install and then stops on - # two packages that publish no win_arm64 wheel at all: - # pyarrow==25.0.0 (pulled in by datasets) -- PyPI has win_amd64 only, - # so uv falls back to the sdist and its CMake configure fails - # hf-transfer==0.1.9 -- a maturin/Rust sdist whose openssl-sys build - # script wants perl, which the image does not have - # The ARM-specific handling added for this platform is working: the log - # shows "windows on arm: skipping torchaudio", and torch 2.10.0+cpu and - # torchvision both install from wheels. The redness that remains is a - # real product gap on this platform, not a gap in the simulation, so the - # leg stays experimental and keeps reporting it rather than hiding it. + # Windows on ARM reaches the dependency install and stops on two packages + # that publish no win_arm64 wheel at all: + # pyarrow==25.0.0 (via datasets) -- PyPI has win_amd64 only, so uv falls + # back to the sdist and its CMake configure fails + # hf-transfer==0.1.9 -- a maturin/Rust sdist whose openssl-sys build script + # wants perl, which the image does not have + # The ARM handling itself works: the log shows "windows on arm: skipping + # torchaudio", and torch 2.10.0+cpu and torchvision install from wheels. What + # is left is a real product gap on this platform, not a gap in the simulation, + # so the leg stays experimental and keeps reporting it. - os: windows-11-arm winget: 'visible' experimental: true @@ -735,19 +736,18 @@ jobs: run: | New-Item -ItemType Directory -Force -Path logs | Out-Null # Drop preinstalled Python, git, CMake, VS/LLVM and the WindowsApps aliases - # from PATH. A full Visual Studio uninstall is not realistic in CI (registry - # + vswhere discovery, slow, may need a reboot), so PATH and env scrubbing - # is the honest approximation, recorded as such. + # from PATH. A full Visual Studio uninstall is not realistic in CI (registry + + # vswhere discovery, slow, may need a reboot), so PATH and env scrubbing is the + # honest approximation, recorded as such. $drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\', 'CMake', 'Microsoft Visual Studio', 'BuildTools', 'LLVM', 'MSYS', 'mingw', 'Strawberry') # winget is an app-execution alias in ...\Local\Microsoft\WindowsApps, which - # the blanket drop above removes on EVERY leg -- so winget=visible was - # silently running the same no-winget 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 back. windows-11-arm has no - # winget on the hosted image (actions/runner-images#14083), so only - # windows-latest can carry it. + # the blanket drop removes on EVERY leg, so winget=visible was silently running + # the same no-winget fallback as winget=masked. Resolve it before the scrub and + # hand it back through a shim: the visible leg gets winget without the Store's + # python.exe alias returning. windows-11-arm has no winget on the hosted image + # (actions/runner-images#14083), so only windows-latest can carry it. $wantWinget = ('${{ matrix.winget }}' -ne 'masked') -and ('${{ matrix.os }}' -eq 'windows-latest') $wingetCmd = Get-Command winget -ErrorAction SilentlyContinue $scrub = { @@ -774,7 +774,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 -- which is how a leg printing + # both reach the toolcache whatever PATH says. That is how a leg printing # `python ABSENT` still installed with the runner's 3.13.14. foreach ($tc in @("$env:AGENT_TOOLSDIRECTORY\Python", 'C:\hostedtoolcache\windows\Python')) { if ($tc -and (Test-Path $tc)) { @@ -786,11 +786,11 @@ jobs: $newPath = ($kept -join ';') "PATH=$newPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 # install.ps1's Refresh-SessionPath (318-337, called at 1246/1278/1295/1360/ - # 1369/2797) rebuilds $env:Path from the Machine and User registry values, so - # scrubbing only the process PATH lasts until the first bootstrap refresh, - # after which Git/CMake/VS/LLVM are back and the rest of the install is no - # longer clean. The runner is ephemeral, so rewrite the registry copies too. - # Expand first: SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ + # 1369/2797) rebuilds $env:Path from the Machine and User registry values, 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. Expand first: + # SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ # (dotnet/runtime#1442). foreach ($scope in 'Machine','User') { $raw = [System.Environment]::GetEnvironmentVariable('Path', $scope) @@ -813,22 +813,21 @@ jobs: run: | $leaked = @() # `py` too: the launcher lives in C:\Windows, which the scrub keeps, and it - # finds the toolcache Python that the scrub only removed from PATH. + # 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 - # any version registered outside the two renamed toolcache directories gets - # reused and Python bootstrap never runs. Exempting `py` without running it - # left that unchecked. + # leak: Find-CompatiblePython (install.ps1:1130-1153) probes `py` first, so any + # version registered outside the two renamed toolcache directories gets reused + # and Python bootstrap never runs. Exempting `py` left that unchecked. if (Get-Command py -ErrorAction SilentlyContinue) { - # -0p prints the launcher's REGISTRY view. The mask step renames the - # toolcache directory on disk but cannot rewrite those registry entries, - # so -0p keeps naming paths that no longer exist. It is context for a - # failure, never evidence of one -- only a probe that starts counts. + # -0p prints the launcher's REGISTRY view. The mask renames the toolcache + # directory on disk but cannot rewrite those entries, so -0p keeps naming + # paths that no longer exist: context for a failure, never evidence of one. + # Only a probe that actually 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') { @@ -838,13 +837,12 @@ 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 probe that FAILS is the outcome we want, but it leaves $LASTEXITCODE - # non-zero, and Get-Command/Write-Host are cmdlets that never reset it. - # The runner appends + # A FAILING probe is the outcome we want, but it leaves $LASTEXITCODE + # non-zero and cmdlets never reset it. The runner appends # if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE } - # to every pwsh step (actions/runner#351), so all three Windows legs - # exited 1 with no ::error:: printed, on machines that were in fact clean - # -- and never reached the Install step at all. + # to every pwsh step (actions/runner#351), so all three Windows legs exited 1 + # with no ::error:: printed, on machines that were in fact clean, and never + # reached the Install step. $global:LASTEXITCODE = 0 } # Printing alone could not fail, and the leg was green while not clean: run @@ -862,17 +860,16 @@ jobs: exit 1 } } elseif ('${{ matrix.os }}' -eq 'windows-latest' -and -not $winget) { - # Without this the visible leg quietly degrades into a second masked leg - # and no job in this workflow exercises the normal winget bootstrap. + # Without this the visible leg quietly degrades into a second masked leg and + # nothing in this workflow exercises the normal winget bootstrap. 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 the machine - # is clean. Be explicit rather than leaving the runner's appended - # `exit $LASTEXITCODE` to decide. + # Every failure above exits 1 explicitly, so reaching here means clean. Be + # explicit rather than let the runner's appended `exit $LASTEXITCODE` decide. exit 0 - name: Install @@ -883,10 +880,10 @@ jobs: UNSLOTH_CI_SOURCE_OVERLAY: ${{ matrix.overlay && inputs.installer_source != 'published' && github.workspace || '' }} run: | $ErrorActionPreference = 'Continue' - # Windows ships its own published script (install.ps1:3), so `published` - # has a meaning here too. Running the checked-out one regardless made a - # dispatch that asked about unsloth.ai report on this ref instead. Empty on - # pull_request/push, so automatic runs stay on this ref. + # Windows ships its own published script (install.ps1:3), so `published` means + # something here too. Running the checked-out one regardless made a dispatch + # asking about unsloth.ai report on this ref. Empty on pull_request/push, so + # automatic runs stay on this ref. $script = './install.ps1' if ('${{ inputs.installer_source }}' -eq 'published') { Invoke-WebRequest -Uri https://unsloth.ai/install.ps1 ` @@ -898,8 +895,8 @@ jobs: } # No -SkipTorch: install.ps1 has no param block and its parser matches # `--no-torch` only (112-142), so the token was silently dropped and every - # Windows leg installed torch anyway. The assert below needs torch, so ask - # for it explicitly rather than by accident. + # Windows leg installed torch anyway. The assert below needs torch, so get it + # on purpose rather than by accident. & $script *>&1 | Tee-Object -FilePath logs/install.log $rc = $LASTEXITCODE Write-Host "installer exit code: $rc" @@ -923,10 +920,10 @@ jobs: # HONESTY NOTE: the hosted image ships the VC++ 2015-2022 runtime in System32 # and it cannot be removed without breaking the runner, so `import torch` # succeeding here does NOT prove a genuinely clean no-winget machine has the - # runtime: Test-VCRedistInstalled (studio/setup.ps1:875) finds the - # preinstalled DLL and Ensure-VCRedist (891) short-circuits before it needs - # winget. Record that, then assert what CAN fail: torch imports, and the - # masked leg really did take the no-winget path. + # runtime: Test-VCRedistInstalled (setup.ps1:875) finds the preinstalled DLL + # and Ensure-VCRedist (891) short-circuits before it needs winget. Record that, + # then assert what CAN fail: torch imports, and the masked leg really did take + # the no-winget path. $sys32 = Join-Path $env:WINDIR 'System32\vcruntime140_1.dll' Write-Host "preinstalled System32 vcruntime140_1.dll: $(Test-Path $sys32)" $py = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\python.exe' diff --git a/.github/workflows/desktop-app-clean-machine-ci.yml b/.github/workflows/desktop-app-clean-machine-ci.yml index 2079857d80..0bbcfc5938 100644 --- a/.github/workflows/desktop-app-clean-machine-ci.yml +++ b/.github/workflows/desktop-app-clean-machine-ci.yml @@ -11,17 +11,16 @@ # # Hosted runners have no interactive desktop session, so "runs" means: the bundle # installs / mounts / extracts, the binary is present, of the right architecture, and -# passes the gatekeeper checks a user would hit (macOS quarantine + codesign, Windows -# installer exit), the process STAYS UP past its preflight (where an unhappy app dies), -# and it writes tauri.log showing the preflight disposition -- the field that read +# clears the gatekeeper checks a user would hit (macOS quarantine + codesign, Windows +# installer exit); the process STAYS UP past its preflight (where an unhappy app dies); +# and it writes tauri.log with a preflight disposition -- the field that read # `ManagedReady` over an unbootable venv in the bug report. Linux gets the strongest # check: a real webview under Xvfb. name: Desktop app clean machine on: - # Also on PRs touching this job or the machine-stripping scripts: workflow_dispatch - # alone cannot validate a change to the job itself, because dispatch resolves the + # 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: @@ -55,14 +54,13 @@ permissions: contents: read 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 - # resolves the newest desktop-v* release when this is empty. + # 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 resolves + # the newest desktop-v* release when this is empty. REL_TAG: ${{ inputs.release_tag || '' }} UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1' @@ -108,10 +106,10 @@ 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. + # `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. if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }} run: | bash .github/scripts/clean-machine-env.sh mask --remove @@ -138,9 +136,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 "the right architecture" was never asserted. lipo - # is an xcrun shim too, absent once the strip step moved CommandLineTools - # aside; /usr/bin/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" @@ -153,9 +150,9 @@ jobs: 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. `::error::` is - # only an annotation and `echo` exits 0, so the old `|| echo` form 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 @@ -169,21 +166,19 @@ jobs: set -o pipefail APP="$(ls -d /Applications/*Unsloth*.app | head -1)" # A headless runner never clicks Install: preflight sets `not_installed` and - # returns (studio/frontend/src/hooks/use-tauri-backend.ts:252-254) while - # startup-screen.tsx:388-389 waits for the button, so launching alone would - # sit on that screen for 90s and pass without ever running the bundled - # installer. Invoke it as studio/src-tauri/src/install.rs does: --tauri, - # stdin closed, no tty. --tauri rejects a custom studio home - # (install.sh:102-114), so drop the workspace-scoped override. + # returns (use-tauri-backend.ts:252-254) while startup-screen.tsx:388-389 + # waits for the button, so launching alone sits on that screen for 90s and + # passes 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. env -u UNSLOTH_STUDIO_HOME \ bash "$APP/Contents/Resources/install.sh" --tauri \ < /dev/null 2>&1 | tee logs/bundled-install.log 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: - # asserting it stops the venv check passing 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 @@ -221,14 +216,13 @@ jobs: found=1 if grep -qE "desktop_preflight completed disposition=" "$f"; then disposition=1; fi done - # Everything above is `|| true`, so this step could not fail while the - # header sells the tauri.log disposition as an acceptance criterion. - # setup_logging (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally - # at process start, so no log at all means the binary never got that far. + # Everything above is `|| true`, so this step could not fail while the header + # sells the tauri.log disposition as an acceptance criterion. setup_logging + # (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally at process + # start, so no log means the binary never got that far, and the disposition + # line is the field the bug report turned on: a process that hangs before + # preflight must not pass. [ "${found:-0}" = "1" ] || { echo "::error::the app wrote no tauri.log; it never reached setup_logging"; exit 1; } - # setup_logging opens tauri.log at process start, so its existence is implied - # by the launch step. The disposition line is the field the bug report turned - # on, so a process that hangs before preflight must not pass. [ "${disposition:-0}" = "1" ] || { echo "::error::tauri.log records no desktop_preflight disposition; the app never completed preflight"; exit 1; } - name: Restore the runner @@ -285,9 +279,8 @@ 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. WebKit + Xvfb are runtime requirements of the app, and apt - # pulls the .deb's declared deps -- if that list is wrong, this step catches - # it. + # 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 @@ -305,16 +298,27 @@ jobs: echo "BIN=$BIN" >> "$GITHUB_ENV" echo "binary: $BIN" + - name: Strip the developer toolchain + # Same gate as macOS. Without this the Linux rows ignored strip_toolchain + # entirely and ran the bundled installer with the runner's git, gcc, cmake and + # make in /usr/bin, so a bundle that needs a toolchain passed the one workflow + # whose premise is that it must not. After apt: the .deb install needs dpkg. + if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }} + run: | + bash .github/scripts/clean-machine-env.sh mask --remove + set -a; . ./clean-machine.env; set +a + bash .github/scripts/clean-machine-assert.sh absent + - name: Run the bundled installer, the path first launch takes run: | + set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a set -o pipefail - # The launch step below only proves the process stayed alive: on a fresh - # home preflight reports not_installed and the app sits on the install - # screen waiting for a click (use-tauri-backend.ts:252-254, - # startup-screen.tsx:388-389), so a bundle whose embedded install.sh is - # missing or broken passed both Linux rows. tauri.conf.json:56-59 ships - # install.sh as a bundle resource, so find it where the bundle put it and - # run it as install.rs does. + # The launch step below only proves the process stayed alive: on a fresh home + # preflight reports not_installed and the app waits on the install screen for + # a click (use-tauri-backend.ts:252-254, startup-screen.tsx:388-389), so a + # bundle whose embedded install.sh was missing or broken passed both Linux + # rows. tauri.conf.json:56-59 ships it as a bundle resource, so find it there + # and run it as install.rs does. if [ "${{ matrix.kind }}" = "deb" ]; then SH="$(dpkg -L "$(dpkg-deb -f dl/*.deb Package)" | grep -E '/install\.sh$' | head -1)" else @@ -337,6 +341,7 @@ 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. @@ -367,16 +372,19 @@ jobs: found=1 if grep -qE "desktop_preflight completed disposition=" "$f"; then disposition=1; fi done - # Same acceptance criterion the macOS rows already enforce. Everything - # above is `|| true` and the loop skips a missing log outright, so - # without these two lines the step could not fail. setup_logging - # (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally at - # process start, so no log at all means the binary never got that far, - # and the launch step only proves the process stayed alive: an app that - # hangs before preflight completes would otherwise pass both Linux rows. + # 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. [ "${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() + run: bash .clean-machine/restore.sh || true + - name: Upload logs if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -390,8 +398,8 @@ jobs: windows: name: desktop windows runs-on: windows-latest - # 60, not 45: this job now runs the bundled installer, and a full torch install - # on a Windows runner is the slowest of the three platforms. + # 60, not 45: this job runs the bundled installer, and a full torch install on a + # Windows runner is the slowest of the three platforms. timeout-minutes: 60 steps: @@ -419,7 +427,7 @@ jobs: gh release download "$REL_TAG" --repo "$REL_REPO" --pattern '*setup.exe' --dir dl ls -la dl - - name: Strip developer tooling from PATH + - 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 @@ -429,10 +437,59 @@ jobs: run: | $drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\', 'CMake', 'Microsoft Visual Studio', 'BuildTools', 'LLVM', 'MSYS', 'mingw') - $kept = ($env:PATH -split ';') | Where-Object { - $p = $_; $p -and -not ($drop | Where-Object { $p -like "*$_*" }) + $scrub = { + param($entries) + ,@($entries | Where-Object { $p = $_; $p -and -not ($drop | Where-Object { $p -like "*$_*" }) }) } - "PATH=$($kept -join ';')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "PATH=$((& $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. + foreach ($tc in @("$env:AGENT_TOOLSDIRECTORY\Python", 'C:\hostedtoolcache\windows\Python')) { + if ($tc -and (Test-Path $tc)) { + try { Rename-Item -LiteralPath $tc -NewName 'Python.masked' -ErrorAction Stop + Write-Host "masked toolcache python: $tc" } + catch { Write-Host "::error::could not mask $tc ($($_.Exception.Message)); the job would not be clean"; exit 1 } + } + } + # The bundled install.ps1 this job runs calls Refresh-SessionPath (318-337), + # which rebuilds $env:Path from the Machine and User registry values, so a + # process-only scrub lasts until the first refresh and Git/CMake/VS/LLVM come + # back. The runner is ephemeral, so rewrite the registry copies too. Expand + # first: SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ + # (dotnet/runtime#1442). + foreach ($scope in 'Machine','User') { + $raw = [System.Environment]::GetEnvironmentVariable('Path', $scope) + if ([string]::IsNullOrWhiteSpace($raw)) { continue } + $expanded = [System.Environment]::ExpandEnvironmentVariables($raw) -split ';' + try { + [System.Environment]::SetEnvironmentVariable('Path', ((& $scrub $expanded) -join ';'), $scope) + } catch { + Write-Host "::error::could not scrub the $scope PATH ($($_.Exception.Message)); the strip would not survive Refresh-SessionPath" + exit 1 + } + } + foreach ($v in 'VSINSTALLDIR','VCINSTALLDIR','WindowsSdkDir','INCLUDE','LIB','LIBPATH') { + "$v=" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + } + # Prove it: the launcher binary stays, but an interpreter it can still START + # is a leak, because Find-CompatiblePython (install.ps1:1130-1153) probes `py` + # first. `py -0p` is only the launcher's REGISTRY view, which still names the + # paths the rename removed, so a start attempt is the only real evidence. + if (Get-Command py -ErrorAction SilentlyContinue) { + foreach ($v in '-3.11', '-3.12', '-3.13') { + $out = & py $v -c "import sys; print(sys.executable)" 2>&1 + $rc = $LASTEXITCODE + Write-Host ("py {0} -> exit {1}: {2}" -f $v, $rc, (($out | Out-String).Trim() -replace '\r?\n', ' / ')) + if ($rc -eq 0) { Write-Host "::error::toolcache python survived the mask: $out"; exit 1 } + } + # 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 fail on a machine that is clean. + $global:LASTEXITCODE = 0 + } + exit 0 - name: Silent install shell: pwsh @@ -454,13 +511,12 @@ jobs: shell: pwsh run: | # The launch step below only proves the process stayed alive. On a fresh - # profile preflight reports not_installed and the app waits for the user to - # click 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 -- the packaged-app failure the workflow exists to catch, and the - # one thing the macOS and Linux rows now check and Windows did not. - # tauri.conf.json:56-59 ships install.ps1 as a bundle resource, so find it - # where NSIS put it and invoke it as install.rs:326-341 does. + # profile preflight reports not_installed and 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 -- + # the packaged-app failure the workflow exists to catch. + # 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 does. $root = Split-Path -Parent $env:APP_EXE $ps1 = Get-ChildItem -Path $root -Recurse -Filter 'install.ps1' -ErrorAction SilentlyContinue | Select-Object -First 1 @@ -470,7 +526,7 @@ jobs: } Write-Host "bundled installer: $($ps1.FullName)" # --tauri rejects a custom studio home (install.ps1:189-215), so drop the - # workspace-scoped override the same way install.rs scrubs it (354-357). + # override as install.rs does (354-357). Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ` -File $ps1.FullName --tauri *>&1 | Tee-Object -FilePath logs/bundled-install.log @@ -487,8 +543,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' @@ -514,6 +570,7 @@ jobs: if: always() shell: pwsh run: | + $found = $false; $disposition = $false foreach ($f in @("$env:UNSLOTH_STUDIO_HOME\tauri.log", "$env:USERPROFILE\.unsloth\studio\tauri.log")) { if (Test-Path $f) { @@ -522,8 +579,26 @@ jobs: Get-Content $f -Tail 60 Select-String -Path $f -Pattern 'disposition=|can_auto_repair=|ModuleNotFoundError' ` -ErrorAction SilentlyContinue + $found = $true + if (Select-String -Path $f -Pattern 'desktop_preflight completed disposition=' ` + -SimpleMatch -Quiet) { $disposition = $true } } } + # Same acceptance criterion macOS and Linux already enforce. Test-Path, + # Get-Content and Select-String cannot fail, so without these two lines the + # step was decoration and the 90s liveness check was the whole bar. + # 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 completes would otherwise pass. + if (-not $found) { + Write-Host '::error::the app wrote no tauri.log; it never reached setup_logging' + exit 1 + } + if (-not $disposition) { + Write-Host '::error::tauri.log records no desktop_preflight disposition; the app never completed preflight' + exit 1 + } + exit 0 - name: Upload logs if: always() diff --git a/install.ps1 b/install.ps1 index efc3d59da6..8474520970 100644 --- a/install.ps1 +++ b/install.ps1 @@ -2624,28 +2624,22 @@ exit 0 } # ── CI only: overlay a source checkout over the package just installed ── - # Mirrors the same block in install.sh. Not a consumer knob: no command-line - # switch, absent from the usage text, and ignored unless - # UNSLOTH_CI_SOURCE_OVERLAY names a directory holding a pyproject.toml. + # Mirrors install.sh. Not a consumer knob: no switch, absent from the usage + # text, ignored unless UNSLOTH_CI_SOURCE_OVERLAY names a directory holding a + # pyproject.toml. # - # Why it exists: the clean-machine legs run THIS script from a branch, but - # the script installs unsloth from PyPI, which is the consumer path and must - # stay that way. Everything Python-side is then read out of the released - # wheel -- studio/setup.ps1, studio/install_python_stack.py, and every - # requirements/constraints file resolved through Path(__file__) -- so a - # branch could not be validated by the very workflow that exists to validate - # it. The `& $UnslothExe studio setup` call 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__, exactly as the --local - # note on the Tauri overlay above describes, so setup.ps1 comes from the - # branch with no further change here. - # - # --local is deliberately NOT used for this: it also installs - # `unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo`, which - # genuinely requires git, and git absence is exactly what the masked leg - # proves. This overlay is editable + --no-deps only. It resolves no - # dependencies, clones nothing, and builds only unsloth's own pure-Python - # metadata, so it still works with git, cmake and MSVC all missing. + # 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. if ($env:UNSLOTH_CI_SOURCE_OVERLAY) { $CiOverlayRoot = $env:UNSLOTH_CI_SOURCE_OVERLAY if (-not (Test-Path -LiteralPath (Join-Path $CiOverlayRoot "pyproject.toml"))) { diff --git a/install.sh b/install.sh index a2f233fbad..1fd97129cc 100755 --- a/install.sh +++ b/install.sh @@ -4069,25 +4069,20 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then fi # ── CI only: overlay a source checkout over the package just installed ── -# Not a consumer knob: no command-line flag, absent from --help, and ignored -# unless UNSLOTH_CI_SOURCE_OVERLAY names a directory holding a pyproject.toml. +# Not a consumer knob: no flag, absent from --help, ignored unless +# UNSLOTH_CI_SOURCE_OVERLAY names a directory holding a pyproject.toml. # -# Why it exists: the clean-machine legs run THIS script from a branch, but the -# script installs unsloth from PyPI, which is the consumer path and must stay -# that way. Everything Python-side is then read out of the released wheel -- -# studio/setup.sh, studio/setup.ps1, studio/install_python_stack.py, and every -# requirements/constraints file it resolves through Path(__file__) -- so a -# branch could not be validated by the very workflow that exists to validate -# it. Overlaying the checkout as an editable install re-points import studio at -# the working tree, and the existing importlib.resources lookup below then -# finds the branch's setup.sh with no further change. -# -# --local is deliberately NOT used for this: it also installs +# 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 -# requires git, and git absence is exactly what these legs prove. This overlay -# is editable + --no-deps only. It resolves no dependencies, clones nothing, -# and builds only unsloth's own pure-Python metadata, so it still works with -# git, cmake and the C/C++ compilers all missing. +# 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++ +# compilers all being gone. if [ -n "${UNSLOTH_CI_SOURCE_OVERLAY:-}" ]; then if [ ! -f "$UNSLOTH_CI_SOURCE_OVERLAY/pyproject.toml" ]; then echo "[ERROR] UNSLOTH_CI_SOURCE_OVERLAY is set to '$UNSLOTH_CI_SOURCE_OVERLAY' but there is no pyproject.toml there." >&2 diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index dbf93f638d..5b999da84b 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -2736,9 +2736,8 @@ def pip_install_try( env = _install_env_for_cmd(cmd), ) if result.returncode == 0: - # Same reasoning as pip_install: a successful install that built from - # source is exactly what the clean-machine `nobuild` assert exists to - # catch, and it can only see what reaches the log. + # Same reasoning as pip_install below: `nobuild` can only catch a source + # build that reaches the log. if VERBOSE and result.stdout: print(_redact_install_output(result.stdout)) return True @@ -2796,15 +2795,14 @@ def pip_install( **_windows_hidden_subprocess_kwargs(), ) if result.returncode == 0: - # Echo the successful output under UNSLOTH_VERBOSE, the same way - # install.sh's run_install_cmd does. Dropping it made the whole - # dependency phase invisible to anything reading the install log: - # .github/scripts/clean-machine-assert.sh's `nobuild` check greps - # for uv's "Building ==", so a source build here -- and - # this is the step that installs studio.txt, where an sdist-only - # dependency actually shows up -- left it reporting "built: none" - # and the leg green. Redacted, because uv echoes index URLs with - # credentials in them. + # Echo successful output 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. if VERBOSE and result.stdout: print(_redact_install_output(result.stdout)) return