Make the Linux and Windows desktop legs clean, and honour published on every macOS delivery

The desktop workflow claims all three platforms are stripped, but only macOS
and Windows had a strip step and the Windows one scrubbed the process PATH
only. Both gaps let a bundle that needs a developer toolchain pass the one
workflow whose premise is that it must not.

Linux: the job ignored strip_toolchain entirely and ran the bundled install.sh
with the runner's git, gcc, cmake and make in /usr/bin. clean-machine-env.sh
now has a Linux --remove branch that moves the resolved tool binaries aside,
recorded in restore.sh, and the job calls it plus `assert absent` after the apt
step (the .deb install needs dpkg) and before the bundled installer, with a
restore step to match macOS. The loop repeats per tool so a name present in
both /usr/bin and /usr/local/bin is fully masked rather than half masked.

Windows: rewriting $env:PATH does not survive the bundled install.ps1, which
calls Refresh-SessionPath (318-337) and rebuilds $env:Path from the Machine and
User registry values, and py.exe in C:\Windows reaches the toolcache whatever
PATH says. Ported the on-disk toolcache rename, the Machine/User registry scrub
and the py -3.11/-3.12/-3.13 start probe from clean-machine-install-ci.yml, so
the strip is proven rather than assumed.

Windows preflight: the log step was Test-Path, Get-Content and Select-String,
none of which can fail, so an app that hangs before preflight passed on the
90 second liveness check alone. It now asserts a tauri.log exists and carries a
`desktop_preflight completed disposition=` line, the same unconstrained check
macOS and Linux already make. The disposition VALUE is deliberately not
constrained: ManagedReady over an unbootable venv is the reported bug.

installer_source on macOS: only the pipe delivery branched on it, so a
`published` dispatch ran the checked-out script on six of the eight macOS rows
while the run was labelled published. The script is now resolved once at the
top of the Install step and used by the file and tauri deliveries; pipe still
re-fetches through the live transport, because that is half of what it tests.
Linux, WSL and Windows already honoured the input.

Also shortened the comments across the changed files, keeping the reasoning
that says why each check exists.
This commit is contained in:
danielhanchen 2026-07-28 23:54:57 +00:00
commit b905784f77
7 changed files with 455 additions and 383 deletions

View file

@ -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 <pkg>==<ver>" 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 <pkg>==<ver>" 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 <name>==<version>` 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 <name> @ 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 `<name>==<version>`, so
# this drops no real signal -- a genuine sdist pulled from PyPI is still
# caught, including one named unsloth.
# `Building <name> @ 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 `<name>==<version>`, 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://" \

View file

@ -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 ─────────────────────────────────────────────────────────────────────

View file

@ -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 <root>/llama.cpp and the venv at
# <root>/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 <root>/llama.cpp and the venv at
# <root>/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'

View file

@ -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()

View file

@ -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"))) {

View file

@ -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

View file

@ -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 <pkg>==<ver>", 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 <pkg>==<ver>" -- 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