Compare commits

...
Sign in to create a new pull request.

41 commits

Author SHA1 Message Date
Daniel Han
c6219fa4d3 Merge remote-tracking branch 'origin/main' into r7551 2026-07-29 07:52:59 +00:00
danielhanchen
f09a1e71a9 Tighten the clean-machine CI comments
Comments only, no assertion logic, pins or leg definitions touched.

Reflowed every rationale block to denser wording and removed the
duplication that had built up across repeated steps: the desktop
workflow repeated the fork-PR skip, the desktop-v* tag resolution and
the restore-runner note once per platform, and the installer workflow
repeated its path-filter rationale in both the pull_request and push
blocks. Those now point at the first copy.

Every WHY is kept: why the masked legs avoid install.sh --local, what
UNSLOTH_CI_SOURCE_OVERLAY is for, why `absent` tests "must not work"
rather than command -v, why the .venv_t5_* sidecars are in the macho
scan scope, why the signature check is main-executables-only, why each
nobuild allowlist entry is a pure-Python sdist, why the WSL job gates
and what the pipe truncation was, and why the virgin container's
overlay=false row is still pinned.

Proved comments-only three ways: both workflow revisions parsed with
yaml.safe_load_all and every leaf walked (only `run:` scalars differ);
every changed bash body and .sh compared byte-for-byte after
`bash --pretty-print -n`; every changed pwsh body and .ps1 compared as
a token stream with Comment and NewLine tokens dropped. A negative
control injecting one non-comment line into each layer makes all of
them fail.
2026-07-29 06:33:16 +00:00
Daniel Han
d6d2408ce6 Retire the #7549 pins and add a wget-only Linux leg
#7549 is in main, so the three known-outcome pins that were waiting on it are
stale and would now hard-error by design. Each is replaced by the assertion it
was standing in for rather than deleted:

win windows-11-arm now gates. The x64-on-ARM64 resolver is asserted as an
outcome: the venv interpreter reports win-amd64 from its own sysconfig, and
torchaudio (no win_arm64 wheel at any version) is installed. Measured on the
integration branch before #7549 merged: "only a native ARM64 Python 3.13 was
found" -> "installing x64 Python" -> torchaudio 2.10.0+cpu, install green.

win windows-latest / winget=masked now gates. The relaxed git gate is asserted
from both sides: the old unconditional message must be absent, the no-git
branch must have been reached (so the row cannot pass because git leaked back
onto PATH), and setup.ps1 must report git as absent-but-not-required.

Both Windows rows, and the visible one, gained the usability check the Linux
legs have had and Windows never did: a managed interpreter, an unsloth CLI on
disk, and that CLI actually running. nobuild and the toolchain check only read
the log, so an installer that exited 0 having produced nothing satisfied them.
The torch assert also loses its fallback to whatever `python` resolves to.

The virgin container overlay row gates, and asserts what only that lane can:
it is the one environment whose System32 does not already ship the VC++
2015-2022 runtime, so it is the only place Ensure-VCRedist's direct aka.ms
download can be proved to run rather than be short-circuited. The overlay=false
row keeps a pin, with a new reason: it installs unsloth from PyPI on purpose,
and setup.ps1 inside 2026.7.5 (uploaded the 23rd) predates #7549, so it still
stops at the old gate. That is release lag, it flips on the next release, and
the pinned signature is now the old wording rather than "#7549 has not landed".

Also adds linux ubuntu2404-nonroot-wget. install.sh's download() takes curl or
wget and _transport_missing is true only when both are gone, so a wget-only box
is supported on paper, but the gating nonroot leg provisions ca-certificates
AND curl, so curl won every probe and the wget branch had never run. Same image,
same no-sudo user, same asserts, wget instead of curl, and curl proved absent on
disk for root and for tester before AND after the install, so the claim is that
every download went through wget rather than that curl happened to be unused.
2026-07-29 05:45:10 +00:00
Daniel Han
94056e0c09 Merge remote-tracking branch 'origin/main' into r7551 2026-07-29 05:32:38 +00:00
Daniel Han
467d02b1dd Re-assert toolchain absence after the desktop .deb pulls its dependencies 2026-07-29 05:06:42 +00:00
Daniel Han
906ef42ead Tighten the clean-machine comments 2026-07-29 05:04:38 +00:00
Daniel Han
b992e72750 Narrow the container pin to its own gates and scan uv and the venv interpreter for arch 2026-07-29 04:58:37 +00:00
Daniel Han
31eb3aed45 Run the clean-machine workflow for the prebuilt installer helpers it overlays 2026-07-29 04:54:52 +00:00
Daniel Han
c6168a31fa Retry the container root CA seeding instead of failing on one Windows Update timeout 2026-07-29 04:54:12 +00:00
Daniel Han
404e38baf9 Assert no source build on the hosted Windows legs and keep winget for the desktop lane 2026-07-29 04:52:27 +00:00
Daniel Han
22495b3485 Gate the no-elevation Linux install and split off the no-transport case 2026-07-29 04:43:43 +00:00
Daniel Han
04ca461b55 Retire the WSL pipe pin now that #7548 is in main
The pin flipped exactly as designed: it looks for _unsloth_main in the installer
it actually ran, and #7548 put it there. Delete the pin and the CLI waiver, and
assert the opposite instead.

WSL is the only platform whose install shells out to Windows interop mid-script,
and interop relays the stdin it inherited, so this job is the one that can catch
the pipe being drained again. A truncation here is now a hard failure.
2026-07-29 04:18:57 +00:00
Daniel Han
1a6d0da800 Merge remote-tracking branch 'origin/main' into r7551 2026-07-29 04:18:46 +00:00
Daniel Han
a1cd794239 Merge remote-tracking branch 'origin/main' into r7551 2026-07-29 04:16:56 +00:00
Daniel Han
0f760008ce Pin the WSL pipe truncation and the masked-winget git gate
The WSL leg dies at install.sh:2082 with an unterminated quoted string.
Nothing is wrong with that line: piping the script into sh is not atomic.
dash reads it from the pipe in 8192-byte blocks and runs each command as
it parses, and install.sh:2007 calls _maybe_reroute_strixhalo_to_2404,
which on WSL alone shells out to Windows interop; interop relays the
stdin it inherited and drains the pipe. dash has 11 blocks buffered at
that point, ending at byte 90112, which falls inside
"$STUDIO_LOCAL_INSTALL" on line 2082. Truncating install.sh at 90112
and parsing it reproduces the message verbatim, and running the whole
file under a stdin-draining interop stub reproduces the exit code too.
#7548 wraps the body in _unsloth_main so sh parses everything before
running anything, and the same reproduction against its head is clean.

The eight green staging runs cited when this job's continue-on-error came
off were all on trees that already carried #7548, so that evidence never
covered this branch. Pin the exact signature instead: exit 2 plus the
shell's own unterminated-quoted-string error, with the _unsloth_main
marker read back out of the distro as the flip condition.

Pin winget=masked the same way. studio/setup.ps1:1655-1669 gates on git
unconditionally and can only fetch it through winget, so masking winget
leaves no way to satisfy it. #7549 relaxes the gate, and its wording
appearing in the tree retires the pin.
2026-07-29 03:58:54 +00:00
Daniel Han
c6a1174e67 Pin the macOS desktop legs on the same pre-7547 release lag
The Linux rows already pin the shipped bundle's own install.sh exiting 2 at the
NEED_SUDO handshake. macos-15 and macos-26 fail the same way for the same reason:
desktop-v0.1.50-beta predates #7547, so the bundled installer still hard-exits on
the Xcode CLT gate that #7547 turned into a warning.

Accept exit 1 plus that exact gate line, and nothing else. _check_macos_deps is
the function #7547 added, so its presence in the bundle means the release caught
up and the block errors out asking for the pin to be deleted.
2026-07-29 03:19:21 +00:00
Daniel Han
d1223f7cc0 Assert the root Linux legs did not compile llama.cpp with the apt-installed toolchain 2026-07-29 02:56:15 +00:00
Daniel Han
58e5fa5e6e Make the WSL job gate, assert Windows installed no toolchain, strip before the .deb 2026-07-29 02:52:45 +00:00
Daniel Han
9b0f6d76d4 Check signatures on Mach-O main executables only
The macho check asserted a valid signature for every Mach-O under the studio
home, and failed the macos-15 mask/pipe leg on 29 files: lxml, charset_normalizer,
cygrpc, _upb, fontTools, caio, brotli and a bundled libportaudio.dylib. Those are
MH_BUNDLE and MH_DYLIB images dlopen'd into a process without library validation,
they ship unsigned in the wheels, and the same run had already installed and
imported them with the installer exiting 0.

Key the signature half off the Mach-O filetype and run it only on main
executables. Report an absent seal separately from one that fails to verify, and
capture codesign output instead of piping it into grep, which returned the
unsigned exit status through pipefail and called every unsigned binary broken.

The architecture half is unchanged and still a hard failure: it is what closes
the Rosetta 2 gap. The zero-Mach-O guard is unchanged. The .venv_t5_* sidecars
stay in scope; setup.sh creates them during a normal install and
transformers_version.py puts them on sys.path, so they are payload.
2026-07-29 02:44:02 +00:00
Daniel Han
627ca71179 Pin the two failures no change here can fix, and add the virgin Windows container lane
Three red checks, two of which test something this branch does not own.

desktop linux deb / appimage run the SHIPPED bundle's own install.sh, and
desktop-v0.1.50-beta was cut on 2026-07-21, before #7547 merged on 07-29. That
bundle still carries the old optional-dependency gate, so on a stripped runner it
exits 2 at [TAURI:NEED_SUDO] cmake git build-essential libcurl4-openssl-dev and
never creates a venv. Current main's _check_linux_deps runs the same set through
_SMART_APT_OPTIONAL, which suppresses every escalation path, so only a new release
can change this. The step now pins that exact outcome: the exit code must be 2 and
the log must carry exactly that package list, anything else still fails, and
finding _SMART_APT_OPTIONAL in the extracted install.sh (the guard #7547 added)
turns into a hard error saying to delete the pin. The venv and torch assertions
stay and still run whenever the installer succeeds.

win windows-11-arm gets a native ARM64 CPython, and torchaudio publishes no
win_arm64 wheel at any version, so the PyTorch step cannot resolve. The fix is in
install.ps1 on #7549, still open. Same treatment: the Install step is
continue-on-error and a new step requires all three of the PyTorch step, the
torchaudio resolution error and the missing win_arm64 platform tag, so any other
failure is red. The row leaves experimental so the job is required, and the pin
errors out as soon as the venv interpreter reports anything but win-arm64, which
is what #7549 landing looks like.

Adds the virgin Windows container lane as two jobs here rather than a sibling
workflow: same premise as the win legs, same path filters, and masked-versus-real
reads better side by side. The hosted Windows legs cannot test the VC++
2015-2022 runtime (it ships in the runner image's System32) or a Windows with no
Microsoft Store, and a servercore:ltsc2022 container on windows-2022 answers both.
The probe asserts no python, py, git, cmake, cl, winget or uv on PATH, on disk or
in the registry, and now also asserts vcruntime140.dll, vcruntime140_1.dll and
msvcp140.dll are absent, which is the one thing the hosted runner cannot un-ship.

Both container install rows stop at studio/setup.ps1's winget-only git gate on
this branch, since #7549 is what relaxes it, so both are pinned the same way. The
overlay row additionally requires the UNSLOTH_CI_SOURCE_OVERLAY hook to have
fired, unconditionally: without that it would be indistinguishable from the
released-wheel row, and the hook is this branch's own feature.

Container notes carried over from the spike: never docker pull when the image is
cached, since MCR has shipped an image ahead of the runner host before; wait for
the Docker daemon, because one leg died in 21s on npipe:////./pipe/docker_engine
and that flake misreads as "Windows containers unavailable"; drive docker from a
run: step, because the job-level container: key is Linux-only. The root CA store
is seeded after the virginity assertion, restoring what a real Windows already
has, because studio/install_node_prebuilt.py downloads Node with bare
urllib.request.urlopen and hits CERTIFICATE_VERIFY_FAILED against the empty
container ROOT store. That product bug is left alone here.
2026-07-29 02:37:53 +00:00
Daniel Han
b5042eed84 Merge remote-tracking branch 'origin/main' into r7551 2026-07-29 01:57:50 +00:00
danielhanchen
83c3dba5ab Close the free headroom in the clean-machine simulation
Assert arch and signature on every downloaded Mach-O. This is the one genuine
gap the simulation had: Rosetta 2 is preinstalled on hosted runners and absent
from a factory-fresh Mac, so an x86_64-only llama.cpp, whisper.cpp, Node or uv
payload runs green here and dies with "bad CPU type in executable" for the
user. llama-server launching under `assert-llama-loads.sh` does not rule that
out, because Rosetta makes it launch. The new `macho` check reads `file -b`
(`lipo` is an xcrun shim and is gone after masking, as the desktop lane already
notes) and keys the expected arch off `uname -m`, so macos-15-intel expects
x86_64. It also requires at least an ad-hoc signature on arm64, which closes
the AMFI "Killed: 9" class that uv has already been bitten by; the check is
skipped on x86_64, where unsigned code loads fine and so is not the same
defect. It fails when the scan finds nothing, since an empty scan reads exactly
like a clean one.

Make absence real rather than PATH-hidden. uv probes well-known interpreter
locations and the framework loader ignores PATH entirely, so hiding the
toolcache only hid it from `command -v`. Empty /usr/local (it EXISTS on a
factory-fresh Mac as a SIP-exempt firmlink, and is empty; it is /usr/local/bin
that is absent, so the directory itself stays), move the hosted toolcache and
/Library/Frameworks/Python.framework aside, and clear the developer dotdirs and
caches. A populated uv or pip cache can also satisfy a resolution that would
fail on a user's machine. Every removal goes through --remove and is recorded
in the generated restore.sh, guarded so a path the install recreated is not
buried inside its own restore.

Unset CI, GITHUB_* and RUNNER_* for the installer process only. An installer
branching on CI=true is a hidden dependency no consumer exercises. Scoped to
the child so the step's own $GITHUB_OUTPUT still resolves.

Record spctl --status and csrutil status. Neither is documented for these
images and both change what a binary is allowed to do.
2026-07-29 01:56:34 +00:00
Daniel Han
7d4311fe58 Skip the desktop jobs on fork PRs instead of failing them
Every desktop-v* release in this repo is a draft, and GitHub lists drafts only
to a token with push access, which is why resolving one needs contents: write.
A pull request from a fork receives a read-only token no matter what the
workflow declares, so on those runs the resolver cannot see any release and the
job died on "no desktop-v* release visible", accusing the repo of having no
bundle when the real cause is the trigger.

This workflow runs on pull_request for changes to itself and the stripping
scripts, so an outside contributor editing either would have hit that. Guard the
three jobs on the head repo not being a fork. A skipped job is honest here: it
does not claim to have tested a bundle it was never able to download, and it is
not reported as a pass.
2026-07-29 01:11:10 +00:00
danielhanchen
2027e157c3 Resolve the desktop release including drafts, the convention this repo ships
All three desktop legs died at the download step with an empty REL_TAG. The
resolver passed --exclude-drafts while REL_REPO now defaults to
github.repository, and every desktop-v* release in unslothai/unsloth is a draft:
desktop-v0.1.50-beta and desktop-v0.1.471-beta are both drafts carrying the .dmg,
.deb, .AppImage and setup.exe, while only the non-desktop tags like v0.1.501-beta
are published. Excluding drafts therefore matched nothing and no leg could ever
run against a production bundle.

Drop --exclude-drafts so the newest desktop-v* release is found. A draft has no
tag ref, so releases/tags/<tag> 404s for one, but gh resolves drafts over GraphQL
and gh release download <tag> fetches their assets normally, so the download call
is unchanged. Listing drafts requires push access, which for GITHUB_TOKEN means
contents: write, so the workflow permission is raised from read and annotated.

When nothing resolves the leg still fails hard rather than skipping: with no
bundle to install there is nothing to prove, so a green run would be a lie. The
error now names both causes, no release cut yet or a token that cannot see drafts.

Also stop the restore step swallowing its own failure. `bash
.clean-machine/restore.sh || true` printed "No such file or directory" whenever an
earlier step failed before the toolchain was stripped, and hid a genuinely broken
restore just the same. Skip explicitly when the file is absent and let a real
restore failure surface. Same fix in clean-machine-install-ci.yml, which had the
identical line.
2026-07-29 01:08:51 +00:00
danielhanchen
d5f747ef5e Assert the Windows desktop strip actually took effect
The desktop job's Windows masking renamed the toolcache Python, scrubbed the
Machine and User registry PATH, and probed `py`, but nothing checked that
`python`, `git`, `cmake` or `cl` were gone. The drop list is heuristic path
fragment matching, so a runner image that moves any of those outside those
fragments leaves the bundled install.ps1 reusing hosted developer tooling while
the job still reports a clean machine. PATH written to $GITHUB_ENV only applies
to later steps, so the check has to live in a step of its own; it carries the
same event gate as the strip, exempts `py` (it lives in C:\Windows and stays,
which is why the start probe is the real evidence), and resets $LASTEXITCODE
before exiting 0 so an intentionally failing probe cannot fail a clean machine.

Also correct the no-winget matrix note: that leg is not failing for an unfixed
product reason. It stops at the unconditional git gate in setup.ps1 only on this
ref, and with that gate relaxed it passes along with every other leg, so the row
is a merge order dependency and stays required.
2026-07-29 00:22:54 +00:00
danielhanchen
b8a052080e Run the Windows installer under PowerShell 5.1, the only shell a clean machine has
The Windows Install step ran `& $script` inside a `shell: pwsh` step, so
install.ps1 was executing under PowerShell 7. A genuinely clean Windows box
does not have PowerShell 7: Windows ships powershell.exe (Windows PowerShell
5.1) and pwsh is a separate install that the hosted runner image happens to
preinstall. So the one workflow whose premise is a machine that has never seen
a developer toolchain was testing the installer under a shell that machine
would not have, and no other Windows job anywhere in .github exercises
install.ps1 under 5.1.

Invoke it the way the desktop does (install.rs:325-339, and the bundled
installer step in desktop-app-clean-machine-ci.yml): powershell.exe with
-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File. The pwsh
step wrapper stays, since it is only the installer that has to be under 5.1.
Calling powershell.exe with `&` keeps the output in the pipeline, so
Tee-Object still fills logs/install.log, and $LASTEXITCODE after the pipeline
is the child's real exit code, so $rc and `exit $rc` are unchanged.

install.ps1 and studio/setup.ps1 hold no PowerShell 7-only constructs: no
`#Requires` above 5.1, no `&&`/`||` chain operators, no ternary, no
null-coalescing, no ForEach-Object -Parallel, no $IsWindows/$PSStyle, and no
6+ cmdlets or parameters. setup.ps1 declares `#Requires -Version 5.1`, and its
three $PSVersionTable branches gate a 7-only preference on the 7 side with a
5.1 fallback. Every Invoke-WebRequest already passes -UseBasicParsing, which
5.1 needs because it otherwise reaches for the IE engine.
2026-07-28 23:59:35 +00:00
danielhanchen
b905784f77 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.
2026-07-28 23:54:57 +00:00
danielhanchen
06d2725e09 Exercise the bundled Windows installer, and stop mislabelling installer sources
Four things that let a leg go green while proving nothing.

The desktop Windows job installed the bundle and launched it, and that was all.
On a fresh profile preflight reports not_installed and the app sits on the
install screen waiting for a click, so the process happily stays alive for 90
seconds without the bundled install.ps1 ever running. A bundle that shipped no
install.ps1 resource, or a broken one, passed this job -- which is the packaged
app failure the workflow exists to catch. macOS and Linux already invoke their
bundled script directly; Windows now does the same, via the resource NSIS laid
down next to the exe, invoked the way install.rs invokes it, then asserts the
managed venv exists and can import torch. Its timeout goes to 60 minutes
because a full torch install on a Windows runner is the slowest of the three.

A manual run that selects installer_source: published only redirected the macOS
and Linux jobs. WSL kept copying the checked-out install.sh and Windows kept
running the checked-out install.ps1, so a run asking whether the script on
unsloth.ai works reported on this ref under the published label. Both now honor
the selection; install.ps1 advertises its own unsloth.ai URL, so published has a
meaning on Windows too. Both branches stay empty on pull_request and push, so
automatic runs are unchanged.

The push-to-main filter listed only install.sh, install.ps1 and this workflow,
while the PR filter also covers setup.sh, setup.ps1, install_python_stack.py and
the clean-machine helpers. A direct push touching those skipped the workflow
entirely, so the post-merge backstop never ran for the files the source overlay
was added to cover. The two lists now match.

Neither filter covered studio/backend/requirements, even though the overlay
exists precisely so a constraints change is resolved on a machine with no
compiler and no cached wheels. The update-smoke workflows cannot stand in: they
start from a preinstalled Python and full developer tooling.
2026-07-28 23:18:06 +00:00
danielhanchen
afbdaa09b7 Allowlist the triton-kernels pure-Python sdist, and record why Windows on ARM is red
The two ubuntu2404 root legs went red at "Assert no source build" reporting
triton-kernels. That is not a regression in what the installer does. Those
builds have always happened; they only became visible now that pip_install
stopped discarding uv's output on success, which is what finally let the
nobuild check read the dependency phase at all.

So the question was whether each build actually needs a compiler. Checked
against the real artifacts rather than assumed:

  openai-whisper 20250625, randomname 0.2.1, argbind 0.3.9 -- no version of
  any of the three has ever published a wheel; antlr4-python3-runtime is
  pinned at 4.9.3, below the first release that ships one. All four sdists
  use setuptools.build_meta, declare no ext_modules, and contain no
  .c/.cpp/.pyx/.rs file. Already allowlisted, correctly.

  triton-kernels is the same category and was the only name failing. It is
  pinned to the triton repo's python/triton_kernels subdirectory; that tree
  is 75 files of Python, a four-line pyproject.toml, no setup.py and no
  native source at all. The kernels are Triton DSL compiled at runtime, not
  at install time. It is also a direct URL the installer names itself rather
  than something resolution picked, and only Linux reaches it. It belongs in
  the allowlist, so add it with that reasoning written down.

The allowlist match is now lowercased and underscore-folded on both sides.
The requirement spells the package triton_kernels while uv prints
triton-kernels, and an allowlist that matched only one spelling would pass
by luck rather than by intent. A plain pyarrow sdist is still caught.

The two data-designer @ file:// plugin builds needed nothing: they are
in-tree local paths, already dropped by the same rule that exempts the
source overlay's own build.

Separately, the windows-11-arm leg fails for a real reason and should keep
failing. The ARM handling itself works, the log shows torchaudio being
skipped and torch plus torchvision installing from wheels. What stops it is
that pyarrow and hf-transfer publish no win_arm64 wheel at all, so uv falls
back to their sdists and they fail on CMake configure and on openssl-sys
wanting perl. That is a product gap on the platform, not a gap in the
simulation, so the leg stays experimental and keeps reporting it. Record
that above the matrix entry so the next reader does not re-diagnose it.
2026-07-28 22:47:33 +00:00
danielhanchen
3be21e87cf Put the branch's own Python under test on the clean-machine legs
install.sh and install.ps1 come from the ref under test, but they install
unsloth from PyPI, which is the consumer path and has to stay that way. That
left everything Python-side coming 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__). A branch that changes
constraints.txt or setup.ps1 therefore got a green run that proved nothing
about the change, and some legs proved less than they looked. The Fedora
assertion was already carrying a hand-written workaround for exactly this,
tolerating a triton/git failure on the grounds that the released package lags
the ref.

Legs marked overlay: true now re-point the venv at the ref just before studio
setup runs, through UNSLOTH_CI_SOURCE_OVERLAY: a --no-deps editable install of
the checkout. That makes import studio resolve to the working tree, so the
existing setup-script lookup finds the ref's setup.sh / setup.ps1 and
install_python_stack reads the ref's constraints, with no other change to
either installer.

Not --local: --local additionally installs unsloth-zoo from a git+https URL,
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 holds up
with git, cmake and the compilers all gone. It is not a consumer knob either:
no flag, no usage entry, ignored unless the variable names a directory with a
pyproject.toml in it.

Four legs stay on the released package deliberately, each for its own reason,
recorded in the header: the mac pipe legs keep an end-to-end signal on what a
user actually runs; the trace leg would otherwise answer its own question,
since the editable build calls git through setuptools-scm's file finder; the
non-root Linux leg dies before a venv exists; and WSL only ever receives
install.sh, not a source tree.

Two supporting fixes the overlay depends on or exposes:

install_python_stack.py discarded uv's output whenever a step succeeded, so
the nobuild assertion, which reads the install log, could not see a source
build in the dependency phase at all. That is the phase that installs
studio.txt, where an sdist-only dependency actually turns up, and it reported
"built: none" regardless. It now echoes successful output under
UNSLOTH_VERBOSE, matching what install.sh's run_install_cmd already does.

nobuild now ignores "Building <name> @ file://" lines. A local-path build is
something the caller pointed at, never a dependency resolution chose, and
index dependencies always print <name>==<version>, so a real sdist from PyPI
is still caught, including one named unsloth.

Each overlaid leg also asserts it really was overlaid, so an unset variable
cannot quietly put the whole matrix back on the released wheel.
2026-07-28 22:25:13 +00:00
Daniel Han
9a8a749d07 Make the AppImage Linux row actually extract, and hold Linux to the macOS preflight bar
The appimage row invoked the extractor by bare filename, and a command word
with no slash is resolved through PATH rather than the working directory, so
the extraction exited 127 and the bundled-installer assertion below it never
ran. Prefix it with ./ so the row exercises what it claims to.

The Linux log step also asserted nothing: it skipped a missing log with
continue and discarded the grep with || true. The launch step only proves the
process stayed alive for 90 seconds, and the bundled-installer checks do not
exercise the Rust preflight path, so an app that hung before preflight
completed passed both required Linux rows. Require the same
desktop_preflight completed disposition= record the macOS rows already do.
2026-07-28 22:02:47 +00:00
danielhanchen
50afa4d21c Stop the Windows clean-machine check failing on its own probe exit code
All three Windows legs failed "Verify the simulation took effect" with no
::error:: printed at all. The check itself was right: the mask step logged
"masked toolcache python: C:\hostedtoolcache\windows\Python", python/git/cmake/cl
were ABSENT, no `py -3.x` probe started an interpreter, and the winget assertions
were satisfied. The step still exited 1.

The cause is $LASTEXITCODE leaking out of the step. The last external command is
the `py -3.13` probe, which is SUPPOSED to fail; Get-Command and Write-Host are
cmdlets and never reset $LASTEXITCODE, and the runner appends
`if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }`
to every pwsh step (actions/runner#351). So a clean machine reported failure,
and because this step runs before Install, no Windows leg has ever reached the
installer. Clear $LASTEXITCODE after the probe loop and end with an explicit
exit 0. The leak detection is untouched: a surviving python/git/cmake/cl, or a
`py -3.x` that actually starts, still exits 1.

Also print each probe's exit code and output, so the next failure here explains
itself instead of being silent, and label `py -0p` as what it is. The launcher
reads the registry, which the on-disk toolcache rename cannot rewrite, so -0p
keeps naming paths that no longer exist. Unlabelled it reads like a leak.

Accept the Fedora leg's real outcome instead of a message that can be absent

The fedora assertion only accepted the unsupported-package-manager hard exit.
That is still what this ref's install.sh does, but the pending installer change
replaces it with a warning that lets the install continue, at which point the
old grep matches nothing and the step fails for the wrong reason.

Handle both, strictly. If the log shows the newer "using prebuilt llama.cpp
(missing:" warning, the Linux gate demonstrably did not hard-stop, and the only
tolerated failure past that point is release lag: install.sh comes from this ref
while unsloth comes from PyPI, and the released studio/install_python_stack.py
has no "skip triton kernels when git is missing" guard, so it still fetches the
git+https triton_kernels requirement on a machine with no git. Anything else
after that warning fails the step. Otherwise the old hard-exit message is still
required. A missing log, a bootstrap outage or any unrecognised failure all
remain errors, and the step retires to a plain success assertion once a release
ships the no-git skip.
2026-07-28 21:56:16 +00:00
Daniel Han
b573f067d1 Prove the trace wrapper records before trusting an empty trace
The `notools` check reads an absence: it passes when the trace file contains no
compiler, git or brew invocation. A shim directory that never reached PATH
produces exactly the same empty file as an installer that touched nothing, so
the single leg carrying that assertion would stay green no matter what the
installer did. "Verify the simulation actually took effect" only ran for mask
mode, which left the trace leg with nothing checking its own instrumentation.

Call git explicitly after sourcing the environment and require it to appear in
the trace, then truncate the file so the self-test entry does not count against
the install. The call has to be explicit because macOS reaches _has_working_git
only under STUDIO_LOCAL_INSTALL (install.sh:2026), so no consumer leg on that
platform probes git on its own.
2026-07-28 20:18:12 +00:00
Daniel Han
85305a2163 Stop four clean-machine checks from passing over a real failure
Re-run `absent` after the install on the masked macOS legs. It only ran
before, so an installer that quietly selected the Xcode CLT or installed a
compiler left the leg green while every later source build could succeed,
which is the one thing clean-machine-assert.sh says `absent` guards the whole
run against.

Fail the Windows simulation when py.exe can still start an interpreter. The
launcher binary itself may stay, but Find-CompatiblePython probes `py` first
(install.ps1:1130-1153), so an interpreter registered outside the two renamed
toolcache directories gets reused and Python bootstrap is never exercised.
Exempting `py` without ever running it left that unchecked.

Propagate the WSL installer exit code. It was printed and discarded, and the
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.

Run the bundled installer in the Linux desktop jobs. The launch step only
proves the process stayed alive, and on a fresh home preflight reports
not_installed and the app waits on the install screen, so both required rows
passed after 90 seconds without ever touching the shipped install.sh. Locate
the resource in the deb payload or the extracted AppImage, run it the way
install.rs does, and require a managed venv that can import torch.
2026-07-28 20:09:03 +00:00
danielhanchen
6937234f2d Point the nightly at the repo that publishes, and let its checks fail
REL_REPO defaulted to unsloth-test/unsloth-test, which holds one release frozen
at 2026-07-27, while release-desktop.yml publishes into github.repository. The
schedule was re-testing the same fixture forever and could never see a broken
production bundle.

The windows job carried a blanket continue-on-error, so its NSIS assertions
could not gate. lipo -archs prints and exits 0 for a thin binary and `|| true`
swallowed even that, so the architecture was never checked; fall back to file,
which survives the CLT mask. And require the preflight disposition line rather
than the mere existence of tauri.log, which setup_logging creates at process
start regardless.
2026-07-28 19:57:35 +00:00
danielhanchen
231fcc3cf0 Tighten the clean-machine comments
Compress the comment blocks across the clean-machine workflows and
scripts. The explanations of why each check is written the way it is
stay; the padding, restatement and duplication go.

No code or workflow logic changes.
2026-07-28 19:35:50 +00:00
danielhanchen
90ec9462a0 Give the Linux and WSL legs an assertion that can fail
The Linux rows' only post-install gate was nobuild, a log grep, so an installer
exiting 0 having produced nothing kept a required leg green. The WSL job and the
Windows job both already check the install runs; the Linux job now does too.

The WSL detection half only printed its Select-String, and the alternation also
matches "platform linux", so a regression that skipped every WSL-specific
branch would still pass as a plain-Linux install. Assert the exact marker,
stripping ANSI first since step writes the label in reverse video. Probed against
three fixtures: real wsl log passes, platform linux fails, missing log fails.
2026-07-28 19:19:48 +00:00
danielhanchen
eea0433f0e Make the Windows and Linux clean-machine legs honest
The Windows scrub only touched PATH, so the legs were green while not clean: run
30365014702 logged "python ABSENT" and then "Python 3.13 already installed"
with uv resolving C:\hostedtoolcache\windows\Python\3.13.14\arm64\python.exe.
py.exe lives in C:\Windows and uv discovers interpreters itself, so take the
toolcache off disk and fail when tooling survives, instead of only printing it.

The Linux desktop legs never stripped anything, and the tauri.log step was all
|| true so it could not fail. Run the bundled installer the way install.rs does,
with --tauri alone, and assert torch: passing --no-torch skipped the slowest
half of first launch and let the venv check pass over it.

Pin the WSL rootfs to a dated build; current/ is a rolling alias and the digest
next to it is fixed.
2026-07-28 15:13:48 +00:00
danielhanchen
0699e5c72b Make the clean-machine legs able to fail
The toolchain strip never ran on the automatic triggers: inputs exists only for
workflow_dispatch, and GitHub coerces '' and false alike to 0, so
`inputs.strip_toolchain != false` was false. Confirmed on a pull_request run
where the strip step reports skipped. Gate on the event instead.

Also: scrub the Machine and User registry PATH, since install.ps1 rebuilds
$env:Path from them mid-install and the toolchain came back; stop dropping
WindowsApps unconditionally, which removed winget on the winget=visible leg too;
fail rather than annotate when a bundle ships no installer or no CLI; run the
bundled installer, which a headless launch never reaches; resolve the newest
desktop-v* release instead of a pinned immutable tag; and give the two macOS
matrix rows distinct artifact names.
2026-07-28 13:45:35 +00:00
Daniel Han
d2ade8ad1e Point the llama assert at the right root, and name the Intel limitation
The tauri leg installs to the legacy root because --tauri refuses a custom
UNSLOTH_STUDIO_HOME. Its install succeeds end to end, but llama.cpp lives at
<root>/llama.cpp while the venv is at <root>/studio, so the assert was pointed one
level too deep.

On macos-15-intel /usr/bin/git keeps working once the CLT are gone, so it is not
CLT-provided there and no masking can remove it, while cc and clang do become
stubs. Calling that 'masking failed' was wrong. That leg allowlists git
explicitly and says why, so the assert stays strict everywhere else.
2026-07-28 12:23:35 +00:00
Daniel Han
c5b4f5ee86 CI: prove the installer works on a machine with no developer toolchain
No job has ever run the installer on a machine without one.
studio-mac-install-matrix.yml is the only macOS installer job and it runs
'bash install.sh --local --no-torch' on runners that already have the Xcode CLT
selected and setup-python preinstalled, so the CLT gate never fires there, and
--local is precisely the mode that legitimately needs git. Repo-wide there was
zero coverage of xcode-select or CommandLineTools outside install.sh itself.

clean-machine-install-ci.yml runs the installer on a genuinely stripped machine.
macOS legs move /var/db/xcode_select_link, /Library/Developer/CommandLineTools,
/Applications/Xcode*.app and Homebrew aside, so xcode-select -p, git, cc and
clang really do fail, and restore unconditionally afterwards. Removing the
select-link alone is not enough: xcode-select falls through to a full Xcode.app
and re-arms /usr/bin/git. Linux legs use containers, which are genuinely clean.
Windows legs cover winget visible and masked, plus windows-11-arm. A WSL leg
covers the 126 lines of WSL-specific install.sh logic that had no runtime test.

Each macOS leg runs four deliveries: pipe (the advertised command, and the shape
that turns an early exit into curl (56)), file (separates installer logic from
pipe delivery), no-torch, and tauri (stdin closed, no tty, as the desktop app
invokes it). One leg records every toolchain invocation and asserts the trace,
which is the real deliverable: proof the installer never reached for a compiler
rather than proof it happened to succeed.

The asserts test that tools do NOT WORK rather than that they are absent from
PATH. On a real virgin Mac /usr/bin/git and /usr/bin/cc exist as CLT stubs, so
'command -v git' succeeds and only running it tells the truth.

desktop-app-clean-machine-ci.yml installs and launches the SHIPPED desktop app
release on a stripped machine, covering Gatekeeper and quarantine on macOS, NSIS
silent install on Windows, and Xvfb with WebKit2GTK on Linux.

Known limit, stated plainly: hosted macOS runners are developer machines. Masking
reproduces this bug and proves the installer does not invoke a toolchain, but it
cannot prove no hidden dependency exists on a truly virgin Mac. An ephemeral-VM
lane is the follow-up.
2026-07-28 12:07:33 +00:00
11 changed files with 3459 additions and 0 deletions

65
.github/scripts/assert-nobuild.ps1 vendored Normal file
View file

@ -0,0 +1,65 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# The `nobuild` contract from clean-machine-assert.sh, for Windows. A port and not
# `shell: bash`: the scrub drops every `*\Git\*` PATH entry and the bash version needs
# sed/grep/tr/sort out of Git's usr/bin, and it also runs inside the servercore
# container, which has no bash. Both Windows lanes call this one file so the sdist
# allowlist cannot drift.
#
# Usage: assert-nobuild.ps1 -LogPath logs/install.log (exit 1 = a source build)
[CmdletBinding()]
param([Parameter(Mandatory = $true)][string] $LogPath)
if (-not (Test-Path -LiteralPath $LogPath)) {
Write-Host "::error::nobuild requested but $LogPath is missing"
exit 1
}
# "Built an sdist" is NOT "needed a compiler": every name here was verified against its
# own sdist -- setuptools.build_meta backend, no ext_modules, no .c/.cpp/.pyx/.rs file
# -- so its PEP 517 build is a pure-Python copy step. Identical to
# clean-machine-assert.sh, which carries the per-name rationale.
$allow = @('openai-whisper', 'argbind', 'randomname', 'antlr4-python3-runtime', 'triton-kernels')
if ($env:UNSLOTH_ALLOW_SDIST) {
$allow += ($env:UNSLOTH_ALLOW_SDIST -split '\s+' | Where-Object { $_ })
}
# Lowercased and underscore-folded on both sides: a distribution name and the name uv
# prints can disagree on the separator (triton_kernels vs triton-kernels).
$allow = @($allow | ForEach-Object { $_.ToLowerInvariant() -replace '_', '-' })
# [char]27, not "`e": that escape is PowerShell 6+, and under Windows PowerShell 5.1
# it degrades to a literal "e" and the strip eats real text instead of ANSI codes.
$esc = [char]27
$text = (Get-Content -LiteralPath $LogPath -Raw) -replace "$esc\[[0-9;]*[A-Za-z]", ''
$built = @()
foreach ($line in ($text -split "`r?`n")) {
# A local-path build is one the caller pointed at (the CI source overlay), never
# one resolution chose; index dependencies always print `==<version>`.
if ($line -imatch 'building [a-z0-9._-]+ @ file://') { continue }
# pip prints `Building wheel for <pkg>`, uv prints `Building <pkg>==<ver>`
# (astral-sh/uv#11165); the `==` or ` @ ` requirement keeps this off the
# installer's own lowercase "building frontend..." text.
foreach ($m in [regex]::Matches($line, '(?i)building wheel for ([a-z0-9._-]+)|building ([a-z0-9._-]+)(==| @ )')) {
$name = if ($m.Groups[1].Success) { $m.Groups[1].Value } else { $m.Groups[2].Value }
$built += ($name.ToLowerInvariant() -replace '_', '-')
}
}
$built = @($built | Sort-Object -Unique)
$bad = @($built | Where-Object { $allow -notcontains $_ })
$rc = 0
if ($bad.Count -gt 0) {
Write-Host "::error::built from source: $($bad -join ' ') -- these must resolve to wheels on a clean machine"
$rc = 1
} else {
Write-Host "[assert] OK no non-allowlisted source build (built: $(if ($built) { $built -join ' ' } else { 'none' }))"
}
# Independent of package names: a compiler error means a toolchain was needed.
$compilerErr = Select-String -Path $LogPath -Pattern "error: command '(cc|gcc|clang|cl)' failed", 'clang: error', 'cargo: not found', 'Microsoft Visual C\+\+ 14.0 or greater is required'
if ($compilerErr) {
Write-Host '::error::compiler invocation appears in the install log'
$compilerErr | Select-Object -First 10 | ForEach-Object { Write-Host " $($_.Line)" }
$rc = 1
}
exit $rc

261
.github/scripts/clean-machine-assert.sh vendored Executable file
View file

@ -0,0 +1,261 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Assert the clean-machine contract after an install attempt.
#
# absent The toolchain really was absent for the whole run. Catches a leg that
# "passed" because masking silently failed, or because the installer
# quietly installed Xcode CLT behind our back.
# notools The trace recorded no compiler/git/brew invocation (trace mode).
# nobuild Wheels-only: no "Building wheel" from pip, no "Building <pkg>==<ver>"
# from uv. Needs UNSLOTH_VERBOSE=1, or run_install_cmd
# (install.sh:193-243) discards uv's output on success.
# macho Every Mach-O under $MACHO_ROOT is the host architecture, and every
# Mach-O MAIN EXECUTABLE is signed. Closes the Rosetta 2 gap, the one
# divergence masking cannot reproduce.
#
# Usage: bash .github/scripts/clean-machine-assert.sh absent notools nobuild macho
set -uo pipefail
LOG="${INSTALL_LOG:-logs/install.log}"
TRACE="${UNSLOTH_TOOL_TRACE:-}"
rc=0
fail() { echo "::error::$*"; rc=1; }
ok() { echo "[assert] OK $*"; }
for check in "$@"; do
case "$check" in
absent)
# NOT `command -v`: on a virgin Mac /usr/bin/{git,cc} EXIST as CLT stubs, so it
# succeeds and only RUNNING them fails. The invariant is: must not WORK.
if xcode-select -p >/dev/null 2>&1; then
fail "xcode-select -p still resolves to $(xcode-select -p 2>/dev/null); not a clean Mac"
else
ok "xcode-select -p fails (the gate a virgin Mac hits)"
fi
for tool in git cc clang cmake; do
command -v "$tool" >/dev/null 2>&1 || { ok "$tool not on PATH"; continue; }
if "$tool" --version >/dev/null 2>&1; then
# On Intel runners /usr/bin/git is not CLT-provided, so no masking can take
# it away. cc and clang do become stubs and the macOS consumer path needs
# no git, so report rather than fail.
case " ${UNSLOTH_CLEAN_ALLOW_WORKING:-} " in
*" $tool "*)
echo "[assert] NOTE $tool still works ($(command -v "$tool")); allowed on this runner"
continue
;;
esac
fail "toolchain still usable: '$tool --version' succeeded ($(command -v "$tool")); masking failed"
else
ok "$tool present but non-functional (CLT stub), as on a clean Mac"
fi
done
# brew is a plain binary with no stub, so absence from PATH is the right test.
if command -v brew >/dev/null 2>&1; then
fail "Homebrew still on PATH at $(command -v brew); masking failed"
else
ok "brew absent"
fi
;;
notools)
if [ -z "$TRACE" ] || [ ! -f "$TRACE" ]; then
fail "notools requested but no trace file (\$UNSLOTH_TOOL_TRACE=$TRACE)"
else
# git is legitimate under --local (unsloth-zoo comes from a git URL), so that
# leg allow-lists it via UNSLOTH_ALLOW_TOOLS.
allow="${UNSLOTH_ALLOW_TOOLS:-}"
hits=""
while IFS=$'\t' read -r tool rest; do
[ -n "$tool" ] || continue
case " $allow " in *" $tool "*) continue ;; esac
# `xcode-select -p` only ASKS whether a toolchain is selected, and the fix
# is that the installer carries on without one, so it is not USE.
# `--install`, which pops the CLT installer, stays a hit.
if [ "$tool" = "xcode-select" ]; then
case "$rest" in
-p|--print-path|-v|--version|"") continue ;;
esac
fi
hits="$hits $tool"
done < "$TRACE"
if [ -n "$hits" ]; then
fail "installer invoked toolchain:$(echo "$hits" | tr ' ' '\n' | sort -u | tr '\n' ' ')"
echo "---- tool trace ----"; sort -u "$TRACE" | head -50
else
ok "no compiler/git/brew invocation recorded"
fi
fi
;;
nobuild)
# "Built an sdist" is NOT "needed a compiler". Every name below was verified
# against its own sdist: setuptools.build_meta backend, no ext_modules, no
# .c/.cpp/.pyx/.rs file, so its PEP 517 build is a pure-Python copy step.
# openai-whisper, argbind, randomname -- no version ever ships a wheel
# antlr4-python3-runtime==4.9.3 -- pinned below the 4.13.2 wheel
# triton-kernels -- requirements/triton-kernels.txt pins a git URL under the
# triton repo's python/triton_kernels subdir: 75 Python files, a four-line
# pyproject.toml, no setup.py, kernels compiled at runtime. A direct URL the
# installer names itself, not something resolution chose, and only the Linux
# legs reach it (install_python_stack.py skips it on Windows and macOS).
# UNSLOTH_ALLOW_SDIST extends the allowlist.
#
# Lowercased and underscore-folded on both sides: a distribution name and the
# name uv prints can disagree on the separator (triton_kernels vs triton-kernels).
_allow="$(printf '%s' "openai-whisper argbind randomname antlr4-python3-runtime triton-kernels ${UNSLOTH_ALLOW_SDIST:-}" | tr 'A-Z_' 'a-z-')"
if [ ! -f "$LOG" ]; then
fail "nobuild requested but $LOG is missing"
else
# uv prints `Building <name>==<version>`, pip prints `Building wheel for
# <name>` (astral-sh/uv#11165), so match both; the `==` or ` @ ` requirement
# keeps this off the installer's own lowercase "building frontend..." text, and
# ANSI is stripped first so a coloured run (FORCE_COLOR) parses.
# `Building <name> @ file://...` is dropped: a local-path build is one the
# caller pointed at (--local, or the editable overlay), never one resolution
# chose. Index dependencies always print `<name>==<version>`, so a genuine
# PyPI sdist is still caught, including one named unsloth.
_esc=$(printf '\033')
_built="$(sed -E "s/${_esc}\[[0-9;]*[A-Za-z]//g" "$LOG" 2>/dev/null \
| grep -viE "building [a-z0-9._-]+ @ file://" \
| grep -oiE "building wheel for [a-z0-9._-]+|building [a-z0-9._-]+(==| @ )" \
| tr 'A-Z' 'a-z' \
| sed -E -e 's/^building wheel for //' -e 's/^building //' -e 's/(==| @ )$//' \
| tr '_' '-' \
| sort -u || true)"
_bad=""
for pkg in $_built; do
case " $_allow " in *" $pkg "*) continue ;; esac
_bad="$_bad $pkg"
done
if [ -n "$_bad" ]; then
fail "built from source:$_bad -- these must resolve to wheels on a clean machine"
else
[ -n "$_built" ] && say_built="$(echo "$_built" | tr '\n' ' ')" || say_built="none"
ok "no non-allowlisted source build (built: $say_built)"
fi
# Independent of package names: a compiler error means a toolchain was needed.
if grep -qiE "error: command '(cc|gcc|clang|cl)' failed|no such file or directory: 'cc'|clang: error|cargo: not found|error: linker \`cc\` not found" "$LOG"; then
fail "compiler invocation appears in the install log"
grep -iE "error: command '(cc|gcc|clang|cl)' failed|clang: error" "$LOG" | head -10
fi
fi
;;
macho)
# The one thing masking cannot reproduce: Rosetta 2 is preinstalled on hosted
# runners and absent from a factory-fresh Mac, so an x86_64-only payload runs
# green here and dies with "bad CPU type in executable" for the user. `lipo` is an
# xcrun shim and gone after masking, so read `file -b`, keyed off `uname -m`
# (macos-15-intel is x86_64).
#
# SCOPE: all of $MACHO_ROOT, .venv_t5_510/_530/_550 sidecars included. Those are
# payload, not scratch: setup.sh:579-581 creates them during a normal install and
# transformers_version.py:338-348 puts them on sys.path. Any exclusion must be a
# named path rule, never a narrowed find.
root="${MACHO_ROOT:-${UNSLOTH_STUDIO_HOME:-$HOME/.unsloth}}"
want="$(uname -m)"
[ "$want" = "aarch64" ] && want=arm64
if [ ! -d "$root" ]; then
fail "macho requested but $root does not exist"
else
# SCOPE, part 2: the two payloads the install RUNS ON live outside $root.
# `uv venv` links <venv>/bin/python at its base interpreter rather than copying
# it, and the find below has no -L, so the interpreter that ran every install
# step is invisible to it; the uv that fetched it lands in $HOME/.local/bin.
# Both are exactly what Rosetta 2 hides: an x86_64 uv or managed CPython runs
# green here and dies on the factory-fresh Mac this job stands in for.
_macho_targets() {
find "$root" -type f \( -perm -u+x -o -name '*.dylib' -o -name '*.so' -o -name '*.node' \) 2>/dev/null
# -L follows the interpreter symlink; -maxdepth keeps this a bin/ lookup and
# not a second walk of site-packages through the venv's lib64 link. Depth 4
# covers <root>/unsloth_studio, the .venv_t5_* sidecars and the tauri layout's
# <root>/studio/unsloth_studio.
find -L "$root" -maxdepth 4 -type f -path '*/bin/python' 2>/dev/null
for _uv in "$HOME/.local/bin/uv" "$(command -v uv 2>/dev/null || true)"; do
[ -n "$_uv" ] && [ -f "$_uv" ] && printf '%s\n' "$_uv"
done
}
n=0 nexe=0 nout=0 bad_arch="" unsigned="" broken=""
while IFS= read -r f; do
desc="$(file -b "$f" 2>/dev/null || true)"
case "$desc" in *Mach-O*) ;; *) continue ;; esac
n=$((n + 1))
case "$f" in "$root"/*) ;; *) nout=$((nout + 1)) ;; esac
# Substring, not equality: a universal binary lists every slice it carries,
# and one that includes the host arch is fine.
case "$desc" in
*"$want"*) ;;
*) bad_arch="$bad_arch $f [$desc]" ;;
esac
# Signature: MAIN EXECUTABLES ONLY. Asserting it on every Mach-O failed the
# mask/pipe leg on 29 ordinary PyPI extension modules plus libportaudio.dylib:
# MH_BUNDLE/MH_DYLIB images dlopen'd without library validation, shipped
# unsigned, and that run had already imported them with the installer exiting
# 0. macOS enforces on main executables and gatekept .app bundles.
#
# Key off the filetype `file` reports, not the path: a .so may be a bundle or a
# dylib, and an executable may have no extension. The library veto is second so
# a mixed-type fat file counts as a library. Substring tests are
# order-independent (Apple prints `... executable arm64`, GNU `... arm64
# executable`).
_is_exe=0
case "$desc" in *executable*) _is_exe=1 ;; esac
case "$desc" in *"shared library"*|*bundle*) _is_exe=0 ;; esac
# Named rule so a failure says which path matched; the filetype test
# already covers the MH_EXECUTE at .app/Contents/MacOS/<name>.
case "$f" in *.app/Contents/MacOS/*) _is_exe=1 ;; esac
[ "$_is_exe" = 1 ] && nexe=$((nexe + 1))
# arm64 only: the kernel refuses to exec an unsigned arm64 main binary
# ("Killed: 9"), while x86_64 execs it happily, so an unsigned x86_64
# payload is not the same defect.
if [ "$want" = "arm64" ] && [ "$_is_exe" = 1 ]; then
# Ad-hoc counts as signed: arm64 linkers seal ad-hoc by default, so the test
# is "has a seal that verifies", not "has an identity". `spctl` and
# `--strict` would demand an authority and reject ad-hoc.
if ! codesign -v "$f" >/dev/null 2>&1; then
# Nothing to verify and a seal that does not match mean different things.
# Captured, not piped into grep: `codesign -dvv` exits non-zero on an
# unsigned file, and under `pipefail` that is the pipeline's status even
# on a match.
_sig="$(codesign -dvv "$f" 2>&1 || true)"
case "$_sig" in
*"not signed at all"*) unsigned="$unsigned $f" ;;
*) broken="$broken $f" ;;
esac
fi
fi
done < <(_macho_targets | sort -u)
if [ "$n" = "0" ]; then
# An empty scan reads exactly like a clean one, so a wrong root would pass
# and prove nothing.
fail "no Mach-O found under $root; the arch/signature assertion proved nothing"
elif [ "$nout" = "0" ]; then
# Same rule for the roots added above: install.sh always bootstraps uv into
# $HOME/.local/bin, so zero hits outside $root means the extra scan matched
# nothing and uv's architecture went unproven.
fail "no Mach-O outside $root was scanned, so uv and the venv's base interpreter escaped the check"
elif [ -n "$bad_arch" ]; then
fail "Mach-O is not $want, so it runs here only under Rosetta 2, which a fresh Mac does not have:$bad_arch"
elif [ -n "$unsigned" ]; then
fail "unsigned Mach-O main executable, which arm64 macOS refuses to exec:$unsigned"
elif [ -n "$broken" ]; then
fail "Mach-O main executable carries a signature that does not verify:$broken"
else
ok "$n Mach-O files under $root, plus uv and the venv's base interpreter, are $want$([ "$want" = arm64 ] && echo "; all $nexe main executable(s) signed")"
fi
fi
;;
*)
fail "unknown check '$check'"
;;
esac
done
exit "$rc"

223
.github/scripts/clean-machine-env.sh vendored Executable file
View file

@ -0,0 +1,223 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Simulate a virgin developer machine on a GitHub-hosted runner. Two modes, because
# "the tool is absent" and "the installer never called the tool" need different
# mechanisms:
#
# mask Make the toolchain genuinely ABSENT: scrub PATH to OS defaults and (with
# --remove) move the real toolchain aside so `command -v git` correctly
# FAILS. Deliberately no "poison shims": a failing shim is still FOUND by
# `command -v`, which reports the tool as present, the opposite of clean.
# trace Leave the toolchain working behind wrappers that log the call then exec
# the real binary, answering whether the installer ever REACHES for a
# compiler/git without changing behaviour.
#
# Writes shell exports to $CLEAN_ENV_FILE (default ./clean-machine.env) to `source`;
# nothing is exported globally, so other steps keep a normal environment.
#
# Usage:
# bash .github/scripts/clean-machine-env.sh mask [--remove]
# bash .github/scripts/clean-machine-env.sh trace
# source ./clean-machine.env
set -uo pipefail
MODE="${1:-}"
REMOVE=0
[ "${2:-}" = "--remove" ] && REMOVE=1
case "$MODE" in
mask|trace) ;;
*) echo "usage: $0 {mask|trace} [--remove]" >&2; exit 2 ;;
esac
OS="$(uname -s)"
WORK="${CLEAN_MACHINE_DIR:-$PWD/.clean-machine}"
ENV_FILE="${CLEAN_ENV_FILE:-$PWD/clean-machine.env}"
TRACE="$WORK/tool-invocations.log"
BIN="$WORK/bin"
RESTORE="$WORK/restore.sh"
mkdir -p "$BIN"
: > "$TRACE"
: > "$ENV_FILE"
printf '#!/usr/bin/env bash\n# Undo clean-machine-env.sh --remove. Safe to run twice.\nset -uo pipefail\n' > "$RESTORE"
chmod +x "$RESTORE"
# The toolchain we care about: a consumer install must need none of it.
TOOLS="xcode-select xcrun clang clang++ cc c++ gcc g++ git cmake make brew ninja cargo rustc"
note() { echo "[clean-machine] $*"; }
# Move a path aside and record the reverse in restore.sh. PATH scrubbing only HIDES
# these; uv, the py launcher and framework lookups find them regardless, so absence
# has to be real. The restore line is guarded: the install may have recreated the
# path, and an unguarded `mv` would bury the original inside it.
mask_aside() {
local src="$1" dst="${2:-$1.masked}" as=""
[ -e "$src" ] || return 0
[ -w "$(dirname "$src")" ] || as="sudo"
if $as mv "$src" "$dst" 2>/dev/null; then
note "moved $src aside"
printf "[ -e '%s' ] || %s mv '%s' '%s' 2>/dev/null || true\n" "$src" "$as" "$dst" "$src" >> "$RESTORE"
else
note "WARN could not move $src"
fi
}
# ── PATH scrub ────────────────────────────────────────────────────────────────
# Keep only OS-default system dirs: drops Homebrew, the hosted Python toolcache,
# setup-* shims, pipx, cargo and every other preinstalled developer dir.
scrub_path() {
local keep out=""
if [ "$OS" = "Darwin" ]; then
keep="/usr/bin:/bin:/usr/sbin:/sbin"
else
keep="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
fi
local IFS=":"
for d in $keep; do
[ -d "$d" ] && out="${out:+$out:}$d"
done
echo "$out"
}
# ── mask ──────────────────────────────────────────────────────────────────────
if [ "$MODE" = "mask" ]; then
NEWPATH="$(scrub_path)"
{
echo "export PATH='$NEWPATH'"
# UNSET, not a fake path: `xcode-select -p` honours DEVELOPER_DIR and prints it
# verbatim with exit 0, so a nonexistent dir makes the probe SUCCEED. On a clean
# Mac it is unset and the missing xcode_select_link is what makes the probe fail.
echo "unset DEVELOPER_DIR || true"
echo "unset SDKROOT CC CXX CFLAGS CXXFLAGS LDFLAGS CMAKE_GENERATOR CMAKE_PREFIX_PATH || true"
echo "export HOMEBREW_NO_AUTO_UPDATE=1"
echo "export UNSLOTH_CLEAN_MACHINE=1"
} >> "$ENV_FILE"
if [ "$REMOVE" = "1" ] && [ "$OS" = "Darwin" ]; then
# Best effort, each step independent and recorded in restore.sh so an
# `if: always()` step can put the runner back. `xcode-select -p` reads
# xcode_select_link, so removing it reproduces a virgin Mac's gate;
# `xcode-select --reset` is NOT enough, it can reselect a full Xcode.app.
if [ -e /var/db/xcode_select_link ]; then
if sudo rm -f /var/db/xcode_select_link 2>/dev/null; then
note "removed /var/db/xcode_select_link"
echo "sudo xcode-select --switch /Library/Developer/CommandLineTools 2>/dev/null || true" >> "$RESTORE"
else
note "WARN could not remove /var/db/xcode_select_link"
fi
fi
# Moving the CLT dir aside turns /usr/bin/{cc,clang,git} into dead shims, so the
# run also proves the install needs no compiler at all.
if [ -d /Library/Developer/CommandLineTools ]; then
if sudo mv /Library/Developer/CommandLineTools /Library/Developer/CommandLineTools.masked 2>/dev/null; then
note "moved CommandLineTools aside"
echo "sudo mv /Library/Developer/CommandLineTools.masked /Library/Developer/CommandLineTools 2>/dev/null || true" >> "$RESTORE"
else
note "WARN could not move CommandLineTools"
fi
fi
# Xcode.app must go too: with the link removed AND CommandLineTools moved,
# `xcode-select -p` still succeeds, falling through to the image's Xcode bundle
# (observed: /Applications/Xcode_16.4.app/Contents/Developer), which re-arms
# /usr/bin/{git,cc}. A rename is instant whatever the bundle size.
for app in /Applications/Xcode*.app; do
[ -d "$app" ] || continue
if sudo mv "$app" "${app}.masked" 2>/dev/null; then
note "moved $(basename "$app") aside"
echo "sudo mv '${app}.masked' '$app' 2>/dev/null || true" >> "$RESTORE"
else
note "WARN could not move $app"
fi
done
# /usr/local EXISTS on a factory-fresh Mac: a SIP-exempt firmlink, and empty. What
# is absent is its CONTENTS, /usr/local/bin included, so empty it rather than remove
# it. Before the Homebrew block below, so /usr/local/Homebrew is stashed once, with
# one restore line, in the right order.
if [ -d /usr/local ]; then
STASH="$WORK/usr-local"
mkdir -p "$STASH"
for entry in /usr/local/* /usr/local/.[!.]*; do
[ -e "$entry" ] || continue
base="$(basename "$entry")"
if sudo mv "$entry" "$STASH/$base" 2>/dev/null; then
note "emptied /usr/local/$base"
printf "[ -e '/usr/local/%s' ] || sudo mv '%s/%s' '/usr/local/%s' 2>/dev/null || true\n" \
"$base" "$STASH" "$base" "$base" >> "$RESTORE"
else
note "WARN could not move $entry"
fi
done
fi
# The hosted toolcache and the python.org framework are what a PATH scrub cannot
# reach: uv discovers interpreters by probing well-known locations.
mask_aside "${AGENT_TOOLSDIRECTORY:-$HOME/hostedtoolcache}"
mask_aside /Library/Frameworks/Python.framework
# Developer dotdirs and caches. A virgin $HOME has none of these, and a populated
# uv/pip cache can satisfy a resolution that would fail on a user's machine.
for d in .cargo .rustup .nvm .rbenv .pyenv .local .cache \
Library/Caches/uv Library/Caches/pip Library/Caches/Homebrew; do
mask_aside "$HOME/$d"
done
for brewdir in /opt/homebrew /usr/local/Homebrew; do
if [ -d "$brewdir" ]; then
if sudo mv "$brewdir" "${brewdir}.masked" 2>/dev/null; then
note "moved $brewdir aside"
echo "sudo mv '${brewdir}.masked' '$brewdir' 2>/dev/null || true" >> "$RESTORE"
else
note "WARN could not move $brewdir"
fi
fi
done
fi
if [ "$REMOVE" = "1" ] && [ "$OS" = "Linux" ]; then
# A hosted Linux runner keeps git, gcc, cmake and make in /usr/bin, which the PATH
# scrub has to keep, so absence must be made real: move the resolved binaries aside
# (recorded in restore.sh). Versioned siblings like gcc-11 survive, but a consumer
# install invokes the unsuffixed names, which is what `absent` checks.
for tool in $TOOLS; do
# Repeat per tool: a runner can carry the same name in /usr/bin and
# /usr/local/bin, and moving only the first leaves the second on PATH.
for _ in 1 2 3 4; do
real="$(command -v "$tool" 2>/dev/null || true)"
[ -n "$real" ] && [ -e "$real" ] || break
if sudo mv "$real" "$real.masked" 2>/dev/null; then
note "moved $real aside"
echo "sudo mv '$real.masked' '$real' 2>/dev/null || true" >> "$RESTORE"
else
note "WARN could not move $real"
break
fi
done
done
fi
fi
# ── trace ─────────────────────────────────────────────────────────────────────
if [ "$MODE" = "trace" ]; then
for tool in $TOOLS; do
real="$(command -v "$tool" 2>/dev/null || true)"
[ -n "$real" ] || continue
# Logs the call then execs the REAL binary: behaviour unchanged, so the trace
# answers "did the installer reach for this?" honestly.
cat > "$BIN/$tool" <<WRAP
#!/bin/sh
printf '%s\t%s\n' "$tool" "\$*" >> "$TRACE"
exec "$real" "\$@"
WRAP
chmod +x "$BIN/$tool"
done
{
echo "export PATH='$BIN:$PATH'"
echo "export UNSLOTH_TOOL_TRACE='$TRACE'"
echo "export UNSLOTH_CLEAN_MACHINE=trace"
} >> "$ENV_FILE"
fi
note "mode=$MODE remove=$REMOVE"
note "env file: $ENV_FILE"
note "trace: $TRACE"
note "restore: $RESTORE"

View file

@ -0,0 +1,41 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Waits for the Windows Docker daemon on a hosted runner, starting the service if
# it is installed but not running.
#
# Docker is installed on every windows-2022 image (runner-images uses Microsoft's
# install-docker-ce.ps1 without -HyperV, so the daemon serves WINDOWS containers) but
# is not always RUNNING when a job starts: a spike run died 21s in with
# failed to connect to the docker API at npipe:////./pipe/docker_engine
# while a sibling job was fine. Without this wait that flake reads as "Windows
# containers are not available on hosted runners", the wrong conclusion entirely.
[CmdletBinding()]
param([int] $TimeoutMinutes = 5)
$deadline = (Get-Date).AddMinutes($TimeoutMinutes)
while ($true) {
docker info *>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Host "docker daemon is up"
break
}
if ((Get-Date) -ge $deadline) {
Write-Host "::error::the Docker daemon never became reachable within $TimeoutMinutes minutes"
Get-Service docker -ErrorAction SilentlyContinue | Format-List | Out-String | Write-Host
exit 1
}
$svc = Get-Service -Name docker -ErrorAction SilentlyContinue
Write-Host "docker service status: $(if ($svc) { $svc.Status } else { 'NOT INSTALLED' }); retrying..."
if ($svc -and $svc.Status -ne 'Running') {
Start-Service docker -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 5
}
# The failing `docker info` probes leave $LASTEXITCODE non-zero and the runner appends
# `exit $LASTEXITCODE` to every pwsh step (actions/runner#351), so without this reset a
# successful wait still fails the step.
$global:LASTEXITCODE = 0
exit 0

View file

@ -0,0 +1,154 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Runs INSIDE a Windows container, after virgin-windows-probe.ps1 has proved there is no
# toolchain: install.ps1 the way a real user on a bare Windows box runs it, then the same
# assertions the hosted Windows leg makes.
[CmdletBinding()]
param(
[string] $Installer = 'C:\ci\install.ps1',
[string] $LogPath = 'C:\ci-out\install.log',
[string] $Overlay = ''
)
$ErrorActionPreference = 'Continue'
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null
function Section($t) { Write-Host ""; Write-Host "=== $t ===" }
# ── Environment the installer needs to be non-interactive ─────────────────────
Section 'install environment'
# install.ps1:2885-2888 prompts `Start Unsloth Studio now? [Y/n]` when
# [Environment]::UserInteractive is true and stdin is not redirected -- both hold under
# `docker exec` -- so without this the installer BLOCKS FOREVER on Read-Host and the job
# dies on timeout with no diagnosis.
$env:UNSLOTH_SKIP_AUTOSTART = '1'
# install.ps1:254/258 joins $env:USERPROFILE with no null guard. An explicit root also
# keeps the container's state under one directory.
$env:UNSLOTH_STUDIO_HOME = 'C:\studio-home'
$env:UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK = '1'
# Without this uv's output is discarded on success and the nobuild check below can only
# ever report "built: none".
$env:UNSLOTH_VERBOSE = '1'
if ($Overlay) {
$env:UNSLOTH_CI_SOURCE_OVERLAY = $Overlay
Write-Host "overlay: $Overlay"
} else {
Remove-Item Env:\UNSLOTH_CI_SOURCE_OVERLAY -ErrorAction SilentlyContinue
Write-Host "overlay: (none -- tests the released wheel)"
}
foreach ($v in 'UNSLOTH_STUDIO_HOME', 'UNSLOTH_SKIP_AUTOSTART', 'UNSLOTH_VERBOSE', 'UNSLOTH_CI_SOURCE_OVERLAY') {
Write-Host (" {0,-32} {1}" -f $v, [System.Environment]::GetEnvironmentVariable($v))
}
# NOTE: deliberately NOT setting [Net.ServicePointManager]::SecurityProtocol here.
# install.ps1 does not set it either, so setting it in the harness would hide a real
# installer bug. The probe already reported whether the default negotiates TLS 1.2.
# ── Run the installer exactly as the desktop launches it ──────────────────────
Section 'install'
if (-not (Test-Path -LiteralPath $Installer)) {
Write-Host "::error::installer not found at $Installer"
exit 1
}
Write-Host "installer: $Installer ($((Get-Content -LiteralPath $Installer).Count) lines)"
$sw = [System.Diagnostics.Stopwatch]::StartNew()
# A child powershell.exe, not dot-sourcing: same shape as install.rs:325-339, and it
# gives a real process exit code instead of whatever the last statement returned.
& powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass `
-File $Installer *>&1 | Tee-Object -FilePath $LogPath
$rc = $LASTEXITCODE
$sw.Stop()
Write-Host ""
Write-Host "installer exit code: $rc (after $([int]$sw.Elapsed.TotalSeconds)s)"
# ── Assertions ────────────────────────────────────────────────────────────────
$failures = @()
$venv = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio'
$venvPy = Join-Path $venv 'Scripts\python.exe'
Section 'assert: the install produced something usable'
if ($rc -ne 0) {
$failures += "installer exited $rc"
} else {
# As the Linux leg: an installer that exits 0 having done nothing must not pass.
if (-not (Test-Path -LiteralPath $venvPy)) {
$failures += "installer exited 0 but left no managed Python at $venvPy"
Get-ChildItem -Path $env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue | Format-Table | Out-String | Write-Host
} else {
Write-Host "managed python: $venvPy"
& $venvPy -V
}
foreach ($cli in (Join-Path $venv 'Scripts\unsloth.exe'), (Join-Path $env:UNSLOTH_STUDIO_HOME 'bin\unsloth.exe')) {
if (Test-Path -LiteralPath $cli) { Write-Host "unsloth CLI: $cli" }
else { $failures += "installer exited 0 but left no unsloth CLI at $cli" }
}
}
Section 'assert: torch imports'
# On the hosted runner this proves less than it looks: the image ships the VC++
# 2015-2022 runtime in System32, so Test-VCRedistInstalled (setup.ps1:875)
# short-circuits before it needs winget. THIS container is the first environment where
# that is not true, so a failure here is a genuine finding about bare Windows.
if (Test-Path -LiteralPath $venvPy) {
foreach ($dll in 'vcruntime140.dll', 'vcruntime140_1.dll', 'msvcp140.dll') {
$p = Join-Path $env:WINDIR "System32\$dll"
Write-Host (" System32\{0,-20} {1}" -f $dll, $(if (Test-Path $p) { 'PRESENT' } else { 'ABSENT' }))
}
& $venvPy -c "import ctypes.util; print('find_library(vcruntime140):', ctypes.util.find_library('vcruntime140'))"
& $venvPy -c "import torch; print('torch', torch.__version__)"
if ($LASTEXITCODE -ne 0) {
$failures += "torch failed to import from the managed Python (VC++ runtime missing?)"
}
$global:LASTEXITCODE = 0
} else {
Write-Host "skipped: no managed Python"
}
Section "assert: the installer took the no-winget path"
if (Test-Path -LiteralPath $LogPath) {
# install.ps1:1098, the no-winget branch. A container has no Microsoft Store and so
# no App Installer, which puts the fallback path (python.org + astral.sh) under
# test -- the whole reason a container is a good harness.
$noWinget = 'will require Python + uv to be already installed'
if (Select-String -Path $LogPath -Pattern $noWinget -SimpleMatch -Quiet) {
Write-Host "confirmed: installer reported winget as unavailable and used the fallback path"
} else {
$failures += "installer never reported winget as unavailable; it did not take the no-winget path"
}
}
if ($Overlay -and $rc -eq 0) {
Section 'assert: this ref was really put under test'
if (Select-String -Path $LogPath -Pattern 'CI: overlaying source checkout' -SimpleMatch -Quiet) {
Write-Host "overlay applied; this leg exercised this ref's Python"
} else {
$failures += "leg is marked overlay but the installer never overlaid the checkout, so it only tested the released package"
}
}
Section 'assert: no non-allowlisted source build'
# Shared with the hosted Windows legs so the sdist allowlist lives in one place; it
# prints its own diagnosis, so only the verdict is folded in here.
$nobuild = Join-Path $PSScriptRoot 'assert-nobuild.ps1'
if (-not (Test-Path -LiteralPath $nobuild)) {
$failures += "assert-nobuild.ps1 is missing next to this script, so the no-build contract went unchecked"
} else {
& $nobuild -LogPath $LogPath
if ($LASTEXITCODE -ne 0) { $failures += "a non-allowlisted source build appears in the install log" }
}
# ── Verdict ───────────────────────────────────────────────────────────────────
Section 'verdict'
if ($failures.Count -gt 0) {
Write-Host "---- last 60 lines of the install log ----"
Get-Content -LiteralPath $LogPath -Tail 60 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $_" }
Write-Host "-----------------------------------------"
foreach ($f in $failures) { Write-Host "::error::$f" }
Write-Host "VIRGIN WINDOWS CONTAINER INSTALL FAILED ($($failures.Count) problem(s))"
exit 1
}
Write-Host "VIRGIN WINDOWS CONTAINER INSTALL PASSED"
exit 0

178
.github/scripts/virgin-windows-probe.ps1 vendored Normal file
View file

@ -0,0 +1,178 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Runs INSIDE a Windows container, proving the environment is genuinely virgin BEFORE
# anything is installed into it. This is the entire point of the container lane: the
# hosted-runner Windows legs of clean-machine-install-ci.yml only simulate absence
# (rename the toolcache Python dir, scrub the Machine and User registry PATH), while
# this asserts real absence on an image that never had a toolchain. Without it the lane
# proves nothing the masked legs did not already prove.
$ErrorActionPreference = 'Continue'
$failures = @()
function Section($t) { Write-Host ""; Write-Host "=== $t ===" }
# ── The interpreter itself ────────────────────────────────────────────────────
# install.ps1 must run under Windows PowerShell 5.1, which is what a real Windows
# box ships. pwsh 7 is a runner-image extra no user is promised. nanoserver has
# NEITHER, which is why this lane is on servercore.
Section 'interpreter'
Write-Host "PSVersion : $($PSVersionTable.PSVersion)"
Write-Host "PSEdition : $($PSVersionTable.PSEdition)"
Write-Host "CLRVersion : $($PSVersionTable.CLRVersion)"
Write-Host "Host : $($Host.Name)"
if ($PSVersionTable.PSEdition -ne 'Desktop') {
$failures += "PSEdition is '$($PSVersionTable.PSEdition)', not Desktop -- this is not Windows PowerShell 5.1"
}
if ($PSVersionTable.PSVersion.Major -ne 5) {
$failures += "PSVersion is $($PSVersionTable.PSVersion), not 5.x"
}
Section 'operating system'
cmd /c ver
$cv = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction SilentlyContinue
if ($cv) {
Write-Host "ProductName : $($cv.ProductName)"
Write-Host "EditionID : $($cv.EditionID)"
Write-Host "InstallationType: $($cv.InstallationType)"
Write-Host "CurrentBuild : $($cv.CurrentBuild).$($cv.UBR)"
}
Write-Host "USERNAME : $env:USERNAME"
Write-Host "USERPROFILE : $env:USERPROFILE"
Write-Host "LOCALAPPDATA : $env:LOCALAPPDATA"
Write-Host "PROCESSOR_ARCH : $env:PROCESSOR_ARCHITECTURE"
# install.ps1:254/258 does Join-Path $env:USERPROFILE ".unsloth\studio" with no null
# guard, so an unset USERPROFILE aborts under ErrorActionPreference=Stop. The lane sets
# UNSLOTH_STUDIO_HOME, but record whether a bare container would have survived without.
if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
Write-Host "::warning::USERPROFILE is unset in this container; install.ps1's default install root would abort"
}
# ── The assertion the whole lane exists for ───────────────────────────────────
Section 'virginity: developer toolchain must be ABSENT'
# python/py/git/cmake/cl/winget are the six the task names. uv is added because
# install.ps1 would happily reuse a preinstalled one and skip its own bootstrap.
$mustBeAbsent = @('python', 'python3', 'py', 'git', 'cmake', 'cl', 'winget', 'uv')
foreach ($t in $mustBeAbsent) {
$c = Get-Command $t -ErrorAction SilentlyContinue
$where = if ($c) { $c.Source } else { 'ABSENT' }
Write-Host (" {0,-10} {1}" -f $t, $where)
if ($c) { $failures += "$t is present at $($c.Source) -- this container is NOT virgin" }
}
Section 'informational: present but not a developer toolchain'
# OS components, not a toolchain. curl.exe and tar.exe ship in System32 on Server 2022
# and are the only transport into a container with no git; naming them keeps the
# premise honest rather than silently relying on them.
foreach ($t in 'cmd', 'powershell', 'curl', 'tar', 'certutil', 'msiexec', 'reg', 'where', 'pwsh', 'node', 'npm', 'msbuild', 'dotnet', 'gcc') {
$c = Get-Command $t -ErrorAction SilentlyContinue
Write-Host (" {0,-10} {1}" -f $t, $(if ($c) { $c.Source } else { 'ABSENT' }))
}
Section 'virginity: no toolchain on disk either'
# A binary can be off PATH and still be found by uv's interpreter discovery or py.exe's
# registry view -- exactly how the hosted Windows leg once reported `python ABSENT` and
# then installed with the runner's 3.13.14. So check disk and registry too.
$badPaths = @(
'C:\Python27', 'C:\Python3*', 'C:\Program Files\Python*', 'C:\Program Files (x86)\Python*',
'C:\Program Files\Git', 'C:\Program Files\CMake', 'C:\Program Files\Microsoft Visual Studio',
'C:\Program Files (x86)\Microsoft Visual Studio', 'C:\hostedtoolcache', 'C:\ProgramData\chocolatey'
)
foreach ($p in $badPaths) {
# Wildcards can match several dirs; take the first so the message names a real
# path instead of stringifying an array.
$hit = @(Get-Item -Path $p -ErrorAction SilentlyContinue) | Select-Object -First 1
if ($hit) {
Write-Host " PRESENT $($hit.FullName)"
$failures += "toolchain directory exists on disk: $($hit.FullName)"
} else {
Write-Host " absent $p"
}
}
$pyReg = @('HKLM:\SOFTWARE\Python', 'HKCU:\SOFTWARE\Python')
foreach ($k in $pyReg) {
if (Test-Path $k) {
Write-Host " PRESENT $k"
$failures += "a registered Python install exists at $k"
} else {
Write-Host " absent $k"
}
}
Section 'PATH as the container sees it'
Write-Host "Process PATH:"
($env:PATH -split ';') | Where-Object { $_ } | ForEach-Object { Write-Host " $_" }
foreach ($scope in 'Machine', 'User') {
Write-Host "$scope PATH: $([System.Environment]::GetEnvironmentVariable('Path', $scope))"
}
# ── The VC++ runtime question the hosted leg cannot answer ────────────────────
Section 'VC++ runtime (honest measurement)'
# The hosted image ships the VC++ 2015-2022 runtime in System32 and cannot lose it
# without breaking the runner (see the HONESTY NOTE in clean-machine-install-ci.yml),
# so `import torch` succeeding there does NOT prove a no-winget machine has the
# runtime. This container is the only environment in CI that can answer it, so their
# absence is asserted, not merely recorded: if a future base image starts shipping
# them the lane silently degrades into another masked leg.
foreach ($dll in 'vcruntime140.dll', 'vcruntime140_1.dll', 'msvcp140.dll') {
$p = Join-Path $env:WINDIR "System32\$dll"
$present = Test-Path $p
Write-Host (" {0,-20} {1}" -f $dll, $(if ($present) { 'PRESENT' } else { 'ABSENT' }))
if ($present) { $failures += "System32\$dll is present -- this image already ships the VC++ runtime, which is the one thing the hosted runner cannot un-ship" }
}
foreach ($k in 'HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64') {
$r = Get-ItemProperty $k -ErrorAction SilentlyContinue
Write-Host (" {0} -> {1}" -f $k, $(if ($r) { "Installed=$($r.Installed) $($r.Major).$($r.Minor)" } else { 'absent' }))
}
# ── Can the installer's transport work at all here? ───────────────────────────
Section 'outbound HTTPS and TLS'
# install.ps1 never sets [Net.ServicePointManager]::SecurityProtocol, so it inherits the
# .NET Framework default. Test the DEFAULT first: if that fails and Tls12 works, the
# installer has a real portability bug on hardened images, not a container quirk.
Write-Host "default SecurityProtocol: $([Net.ServicePointManager]::SecurityProtocol)"
$probeUrls = @(
'https://www.python.org/ftp/python/',
'https://astral.sh/uv/install.ps1',
'https://pypi.org/simple/',
'https://aka.ms/vs/17/release/vc_redist.x64.exe'
)
$defaultOk = @{}
foreach ($u in $probeUrls) {
try {
$null = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 60 -Method Head -ErrorAction Stop
Write-Host " OK (default TLS) $u"; $defaultOk[$u] = $true
} catch {
Write-Host " FAIL (default TLS) $u -- $($_.Exception.Message)"; $defaultOk[$u] = $false
}
}
if ($defaultOk.Values -contains $false) {
Write-Host "retrying the failures with an explicit Tls12..."
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
foreach ($u in $probeUrls) {
if ($defaultOk[$u]) { continue }
try {
$null = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 60 -Method Head -ErrorAction Stop
Write-Host " OK (Tls12) $u"
Write-Host "::warning::$u needs an explicit Tls12; install.ps1 never sets SecurityProtocol, so this is a real installer portability gap"
} catch {
Write-Host " FAIL (Tls12) $u -- $($_.Exception.Message)"
$failures += "no outbound HTTPS to $u even with Tls12 -- the container cannot reach the installer's download hosts"
}
}
}
# ── Verdict ───────────────────────────────────────────────────────────────────
Section 'verdict'
if ($failures.Count -gt 0) {
foreach ($f in $failures) { Write-Host "::error::$f" }
Write-Host "VIRGINITY ASSERTION FAILED ($($failures.Count) problem(s))"
exit 1
}
Write-Host "VIRGINITY ASSERTION PASSED"
Write-Host "no python, py, git, cmake, cl, winget or uv on PATH, on disk, or in the registry"
exit 0

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,740 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Installs and launches the SHIPPED desktop app on a machine stripped of developer
# tooling, on all three platforms.
#
# studio-tauri-smoke.yml compiles the Tauri crate and release-desktop.yml produces the
# bundles, but neither puts a published artifact on a clean machine and checks that it
# starts -- the gap the reported failures fell through: both came from the packaged app
# running its bundled Contents/Resources/install.sh, which no CI job exercised.
#
# Hosted runners have no interactive desktop session, so "runs" means: the bundle
# installs, the binary is present, of the right architecture, and clears the gatekeeper
# checks a user hits (macOS quarantine + codesign, Windows installer exit); the process
# STAYS UP past its preflight, where an unhappy app dies; and it writes tauri.log with a
# preflight disposition, the field that read `ManagedReady` over an unbootable venv in the
# bug report. Linux gets the strongest check: a real webview under Xvfb.
name: Desktop app clean machine
on:
# Also on PRs touching this job or the stripping scripts: dispatch resolves the workflow
# from the DEFAULT branch, so a new or edited file on a feature branch can never be
# dispatched and would first run only after merging blind.
pull_request:
paths:
- '.github/workflows/desktop-app-clean-machine-ci.yml'
- '.github/scripts/clean-machine-env.sh'
- '.github/scripts/clean-machine-assert.sh'
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag in the desktop release repo'
type: string
default: 'desktop-v0.1.50-beta'
release_repo:
description: 'owner/name hosting the desktop release'
type: string
default: ''
strip_toolchain:
description: 'Strip developer tooling before installing'
type: boolean
default: true
schedule:
# Nightly, so a broken published bundle is caught without anyone asking.
- cron: '17 5 * * *'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
# Drafts are listed only to a token with push access, and every desktop-v* release here
# is a draft, so `contents: read` cannot see the bundle under test at all.
contents: write
env:
# release-desktop.yml publishes into github.repository, so a nightly aimed anywhere else
# goes green over a broken production bundle. unsloth-test/unsloth-test holds one frozen
# release, so the schedule was re-testing the same fixture forever.
REL_REPO: ${{ inputs.release_repo || github.repository }}
# Empty unless dispatched: a pinned tag is an immutable fixture, so a nightly against it
# could never catch a newly published broken bundle. Each download step then resolves
# the newest desktop-v* release, drafts included -- every desktop-v* release here is cut
# as a draft, so --exclude-drafts matched nothing and every leg died resolving.
# releases/tags/<tag> 404s for a draft, but gh looks drafts up over GraphQL, so `gh
# release download <tag>` still fetches their assets.
REL_TAG: ${{ inputs.release_tag || '' }}
UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
jobs:
# ── macOS: .dmg, Apple Silicon ────────────────────────────────────────────
macos:
# A fork PR's token is read-only however this workflow declares permissions, so it
# cannot list the draft releases every desktop-v* bundle is published as. Skip rather
# than fail: a property of the trigger, not a broken release.
if: github.event.pull_request.head.repo.fork != true
name: desktop macOS ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 45
continue-on-error: ${{ matrix.experimental }}
strategy:
fail-fast: false
matrix:
include:
- {os: macos-14, experimental: false}
- {os: macos-15, experimental: false}
- {os: macos-26, experimental: true}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download the shipped .dmg
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p dl logs
# Desktop releases are prereleases (never repo-wide "latest") and drafts, so the
# newest desktop-v* tag has to be resolved explicitly. See REL_TAG above.
if [ -z "$REL_TAG" ]; then
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \
--json tagName,createdAt \
--jq '[.[] | select(.tagName | startswith("desktop-v"))]
| sort_by(.createdAt) | reverse | .[0].tagName // empty')"
# Loud on purpose: there is no bundle to test, so passing would prove nothing.
[ -n "$REL_TAG" ] || {
echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)"
exit 1
}
echo "resolved release tag: $REL_TAG"
echo "REL_TAG=$REL_TAG" >> "$GITHUB_ENV"
fi
gh release download "$REL_TAG" --repo "$REL_REPO" \
--pattern '*aarch64.dmg' --dir dl
ls -la dl
- name: Strip the developer toolchain
# `inputs` exists only for workflow_dispatch, so elsewhere strip_toolchain is '' --
# and loose equality coerces both '' and false to 0, making `!= false` FALSE, so
# automatic runs would keep the very toolchain this removes. Gate on the event.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
run: |
bash .github/scripts/clean-machine-env.sh mask --remove
set -a; . ./clean-machine.env; set +a
bash .github/scripts/clean-machine-assert.sh absent
- name: Mount and install
run: |
DMG="$(ls dl/*.dmg | head -1)"
# A real download is quarantined, and Gatekeeper treats that differently from a
# locally built bundle: a genuine failure mode.
xattr -w com.apple.quarantine \
"0081;$(printf %x $(date +%s));Safari;" "$DMG" 2>/dev/null || true
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint /Volumes/UnslothCI
APP="$(ls -d /Volumes/UnslothCI/*.app | head -1)"
echo "app bundle: $APP"
cp -R "$APP" /Applications/
hdiutil detach /Volumes/UnslothCI -quiet
ls -la /Applications | grep -i unsloth
- name: Inspect the bundle (arch, signature, Gatekeeper)
run: |
APP="$(ls -d /Applications/*Unsloth*.app | head -1)"
BIN="$APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist")"
file "$BIN"
# `lipo -archs` prints and exits 0 for a thin x86_64 binary, and `|| true`
# swallowed even that, so architecture was never asserted. lipo is an xcrun shim,
# gone once the strip moved CommandLineTools aside; file is base system.
ARCHS="$(lipo -archs "$BIN" 2>/dev/null || true)"
[ -n "$ARCHS" ] || ARCHS="$(file -b "$BIN")"
echo "architectures: $ARCHS"
case "$ARCHS" in
*arm64*|*aarch64*) ;;
*) echo "::error::the aarch64 .dmg carries no arm64 binary ($ARCHS)"; exit 1 ;;
esac
# Report rather than gate: an unnotarised beta is expected to fail assessment, but
# a user WILL hit this, so it must be visible.
codesign -dv --verbose=2 "$APP" 2>&1 | head -20 || true
spctl -a -vvv -t install "$APP" 2>&1 | head -5 || \
echo "::warning::Gatekeeper assessment failed -- users see 'cannot be opened' unless notarised"
# The bundled installer is what actually failed for users, and `::error::` is only
# an annotation that `echo` exits 0 from, so `|| echo` let a bundle with no
# installer pass.
if [ -f "$APP/Contents/Resources/install.sh" ]; then
echo "bundled install.sh present"
else
echo "::error::no bundled install.sh in the app"
exit 1
fi
- name: Run the bundled installer, the path first launch takes
run: |
set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a
set -o pipefail
APP="$(ls -d /Applications/*Unsloth*.app | head -1)"
# A headless runner never clicks Install: preflight sets `not_installed` and
# returns (use-tauri-backend.ts:252-254) while startup-screen.tsx:388-389 waits
# for the button, so launching alone sits there for 90s without ever running the
# bundled installer. Invoke it as src-tauri/src/install.rs does: --tauri, stdin
# closed, no tty. --tauri rejects a custom studio home (install.sh:102-114), so
# drop the override.
# KNOWN OUTCOME PIN, retire when the desktop release catches up to #7547. REL_TAG
# predates #7547, so the bundle's own install.sh still hard-exits on the Xcode
# CLT gate that #7547 replaced with a warning. Only a new release can move that,
# not this PR. _check_macos_deps is the function #7547 added, so finding it means
# the release caught up and this pin must go.
SH="$APP/Contents/Resources/install.sh"
if grep -q '_check_macos_deps' "$SH"; then
echo "::error::the bundled install.sh now carries #7547; delete this pin block and let the venv + torch assertions below run unconditionally"
exit 1
fi
rc=0
env -u UNSLOTH_STUDIO_HOME \
bash "$SH" --tauri \
< /dev/null 2>&1 | tee logs/bundled-install.log || rc=$?
echo "bundled installer exit code: $rc"
# Exit code AND the exact gate line, so any other non-zero exit still fails.
if [ "$rc" -eq 1 ] && grep -qE '^==> Xcode Command Line Tools are required\.[[:space:]]*$' logs/bundled-install.log; then
echo "::notice::known pre-#7547 outcome: the shipped bundle's install.sh stopped on the Xcode CLT gate and exited 1. Not a regression here; the next desktop release retires this pin."
exit 0
fi
[ "$rc" -eq 0 ] || {
echo "::error::bundled installer exited $rc, which is neither success nor the pinned pre-#7547 outcome (exit 1 plus '==> Xcode Command Line Tools are required.')"
exit 1
}
PY="$HOME/.unsloth/studio/unsloth_studio/bin/python"
[ -x "$PY" ] || { echo "::error::bundled installer left no venv at $PY"; exit 1; }
"$PY" -V
# install.rs passes only --tauri, so torch is part of first launch: without this
# the venv check passes a bundle whose only failure is the torch install.
"$PY" -c "import torch; print('torch', torch.__version__)"
- name: Launch and prove it stays up
run: |
set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a
APP="$(ls -d /Applications/*Unsloth*.app | head -1)"
BIN="$APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist")"
"$BIN" > logs/app-stdout.log 2>&1 &
APP_PID=$!
# 90s: long enough to clear preflight and start the bundled installer.
for i in $(seq 1 90); do
kill -0 "$APP_PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$APP_PID" 2>/dev/null; then
echo "app still running after 90s (pid $APP_PID)"
kill -TERM "$APP_PID" 2>/dev/null || true
else
wait "$APP_PID" 2>/dev/null; rc=$?
echo "::error::desktop app exited early with rc=$rc"
tail -50 logs/app-stdout.log || true
exit 1
fi
- name: What did its own log say?
if: always()
run: |
for f in "$UNSLOTH_STUDIO_HOME/tauri.log" "$HOME/.unsloth/studio/tauri.log"; do
[ -f "$f" ] || continue
echo "=== $f ==="
cp "$f" logs/ 2>/dev/null || true
tail -60 "$f"
# The two fields the bug report turned on.
grep -E "disposition=|can_auto_repair=|Xcode Command Line|ModuleNotFoundError" "$f" || true
found=1
if grep -qE "desktop_preflight completed disposition=" "$f"; then disposition=1; fi
done
# Everything above is `|| true`, so this step could not fail while the header
# sells the tauri.log disposition as an acceptance criterion. setup_logging
# (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally at process start,
# so no log means the binary never got that far, and the disposition line is the
# field the bug report turned on: a process that hangs before preflight must not
# pass.
[ "${found:-0}" = "1" ] || { echo "::error::the app wrote no tauri.log; it never reached setup_logging"; exit 1; }
[ "${disposition:-0}" = "1" ] || { echo "::error::tauri.log records no desktop_preflight disposition; the app never completed preflight"; exit 1; }
- name: Restore the runner
if: always()
# `|| true` swallowed everything, a genuinely broken restore included. The file
# only exists once the strip step ran, and an earlier step can fail before that,
# so skip explicitly when it is absent and let a real failure surface.
run: |
if [ -f .clean-machine/restore.sh ]; then
bash .clean-machine/restore.sh
else
echo "no .clean-machine/restore.sh: the toolchain was never stripped, nothing to restore"
fi
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-macos-${{ matrix.os }}
path: logs/
retention-days: 7
if-no-files-found: warn
# ── Linux: .deb and .AppImage, with a real webview under Xvfb ────────────
linux:
# See the macOS job: a fork PR's token cannot list drafts, so skip rather than fail.
if: github.event.pull_request.head.repo.fork != true
name: desktop linux ${{ matrix.kind }}
runs-on: ubuntu-22.04
timeout-minutes: 45
continue-on-error: ${{ matrix.experimental }}
strategy:
fail-fast: false
matrix:
include:
- {kind: deb, experimental: false}
- {kind: appimage, experimental: false}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download the shipped bundle
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p dl logs
# See the macOS job. Loud on purpose: no bundle means nothing to prove.
if [ -z "$REL_TAG" ]; then
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \
--json tagName,createdAt \
--jq '[.[] | select(.tagName | startswith("desktop-v"))]
| sort_by(.createdAt) | reverse | .[0].tagName // empty')"
[ -n "$REL_TAG" ] || {
echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)"
exit 1
}
echo "resolved release tag: $REL_TAG"
echo "REL_TAG=$REL_TAG" >> "$GITHUB_ENV"
fi
pat='*.deb'; [ "${{ matrix.kind }}" = "appimage" ] && pat='*.AppImage'
gh release download "$REL_TAG" --repo "$REL_REPO" --pattern "$pat" --dir dl
ls -la dl
- name: Strip the developer toolchain
# Same gate as macOS: without it the Linux rows ignored strip_toolchain and ran the
# bundled installer with the runner's git, gcc, cmake and make in /usr/bin.
#
# BEFORE the bundle install, as macOS and Windows already do: dpkg runs the
# package's own maintainer scripts, so installing first let them see the hosted
# image's toolchain. Nothing in that install needs a masked tool --
# clean-machine-env.sh moves aside only $TOOLS, leaving the package manager itself
# -- and the current bundle ships a postrm and no install-time script.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
run: |
bash .github/scripts/clean-machine-env.sh mask --remove
set -a; . ./clean-machine.env; set +a
bash .github/scripts/clean-machine-assert.sh absent
- name: Install with NO dev tooling, only runtime libs
run: |
# Deliberately not build-essential/cmake/git: a user installing a .deb has none
# of that. Xvfb and WebKit are runtime requirements, and apt pulls the .deb's
# declared deps, so a wrong dependency list fails here.
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends xvfb
if [ "${{ matrix.kind }}" = "deb" ]; then
sudo apt-get install -y ./dl/*.deb || {
echo "::error::the .deb does not declare its runtime dependencies correctly"
exit 1
}
BIN="$(dpkg -L "$(dpkg-deb -f dl/*.deb Package)" | grep -E '/usr/bin/' | head -1)"
else
sudo apt-get install -y -qq --no-install-recommends libfuse2 \
libwebkit2gtk-4.1-0 libgtk-3-0 libayatana-appindicator3-1 || true
chmod +x dl/*.AppImage
BIN="$(ls dl/*.AppImage | head -1)"
fi
echo "BIN=$BIN" >> "$GITHUB_ENV"
echo "binary: $BIN"
# The strip runs before this, but `apt-get install ./dl/*.deb` then pulls the bundle's
# DECLARED dependencies, so a release that adds git, cmake or a compiler to that list
# puts one back in /usr/bin and both required Linux rows still pass. `absent` ran only
# beforehand, so re-run it here, before the bundled installer. The current dependency
# closure is 65 packages of runtime libs and no toolchain, so this is green today and
# only a new dependency can turn it red.
- name: Re-assert the toolchain is still absent after the package install
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
run: |
set -a; . ./clean-machine.env; set +a
bash .github/scripts/clean-machine-assert.sh absent
- name: Run the bundled installer, the path first launch takes
run: |
set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a
set -o pipefail
# The launch step below only proves the process stayed alive: on a fresh home
# preflight reports not_installed and the app waits on the install screen for a
# click (use-tauri-backend.ts:252-254, startup-screen.tsx:388-389), so a bundle
# whose embedded install.sh was missing or broken passed both Linux rows.
# tauri.conf.json:56-59 ships it as a bundle resource, so find it there and run
# it as install.rs does.
if [ "${{ matrix.kind }}" = "deb" ]; then
SH="$(dpkg -L "$(dpkg-deb -f dl/*.deb Package)" | grep -E '/install\.sh$' | head -1)"
else
# ls returns a bare filename here, and a command word with no slash resolves
# through PATH, not the cwd, so this needs the ./ prefix.
(cd dl && "./$(ls *.AppImage | head -1)" --appimage-extract >/dev/null)
SH="$(find dl/squashfs-root -name install.sh -type f | head -1)"
fi
[ -n "$SH" ] && [ -f "$SH" ] || { echo "::error::the bundle ships no install.sh resource"; exit 1; }
echo "bundled installer: $SH"
# KNOWN OUTCOME PIN, retire when the desktop release catches up to #7547. The
# bundle carries its own install.sh and REL_TAG predates #7547, so on a stripped
# runner it still exits 2 at the NEED_SUDO handshake for the optional set instead
# of falling through to prebuilt llama.cpp. Only a new release can move that, not
# this PR. _SMART_APT_OPTIONAL is the guard #7547 added, so finding it means the
# release caught up and this pin must go.
if grep -q '_SMART_APT_OPTIONAL' "$SH"; then
echo "::error::the bundled install.sh now carries #7547; delete this pin block and let the venv + torch assertions below run unconditionally"
exit 1
fi
# --tauri rejects a custom studio home (install.sh:102-114), so drop the
# workspace-scoped override; close stdin as install.rs does.
rc=0
env -u UNSLOTH_STUDIO_HOME \
bash "$SH" --tauri < /dev/null 2>&1 | tee logs/bundled-install.log || rc=$?
echo "bundled installer exit code: $rc"
# Exit code AND the exact optional set, so a different NEED_SUDO list or any other
# non-zero exit is still a failure.
if [ "$rc" -eq 2 ] && grep -qE '^\[TAURI:NEED_SUDO\] cmake git build-essential libcurl4-openssl-dev[[:space:]]*$' logs/bundled-install.log; then
echo "::notice::known pre-#7547 outcome: the shipped bundle's install.sh asked to elevate for the optional set and exited 2. Not a regression here; the next desktop release retires this pin."
exit 0
fi
[ "$rc" -eq 0 ] || {
echo "::error::bundled installer exited $rc, which is neither success nor the pinned pre-#7547 outcome (exit 2 plus exactly '[TAURI:NEED_SUDO] cmake git build-essential libcurl4-openssl-dev')"
exit 1
}
PY="$HOME/.unsloth/studio/unsloth_studio/bin/python"
[ -x "$PY" ] || { echo "::error::bundled installer left no venv at $PY"; exit 1; }
"$PY" -V
# install.rs passes only --tauri, so torch is part of first launch.
"$PY" -c "import torch; print('torch', torch.__version__)"
- name: Launch under Xvfb and prove it stays up
run: |
set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a
# Linux is the one platform where a hosted runner can give the app a real display,
# so this is the strongest "does the UI come up" check available without
# self-hosted hardware.
xvfb-run -a --server-args="-screen 0 1440x900x24" \
"$BIN" > logs/app-stdout.log 2>&1 &
APP_PID=$!
for i in $(seq 1 90); do
kill -0 "$APP_PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$APP_PID" 2>/dev/null; then
echo "app still running after 90s"
kill -TERM "$APP_PID" 2>/dev/null || true
else
wait "$APP_PID" 2>/dev/null; rc=$?
echo "::error::desktop app exited early with rc=$rc"
tail -60 logs/app-stdout.log || true
exit 1
fi
- name: What did its own log say?
if: always()
run: |
for f in "$UNSLOTH_STUDIO_HOME/tauri.log" "$HOME/.unsloth/studio/tauri.log"; do
[ -f "$f" ] || continue
echo "=== $f ==="; cp "$f" logs/ 2>/dev/null || true; tail -60 "$f"
grep -E "disposition=|can_auto_repair=|ModuleNotFoundError" "$f" || true
found=1
if grep -qE "desktop_preflight completed disposition=" "$f"; then disposition=1; fi
done
# Same acceptance criterion the macOS rows enforce, and for the same reason:
# everything above is `|| true` and the loop skips a missing log, so without
# these two lines the step could not fail.
[ "${found:-0}" = "1" ] || { echo "::error::the app wrote no tauri.log; it never reached setup_logging"; exit 1; }
[ "${disposition:-0}" = "1" ] || { echo "::error::tauri.log records no desktop_preflight disposition; the app never completed preflight"; exit 1; }
- name: Restore the runner
if: always()
# See the macOS job: `|| true` would swallow a genuinely broken restore.
run: |
if [ -f .clean-machine/restore.sh ]; then
bash .clean-machine/restore.sh
else
echo "no .clean-machine/restore.sh: the toolchain was never stripped, nothing to restore"
fi
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-linux-${{ matrix.kind }}
path: logs/
retention-days: 7
if-no-files-found: warn
# ── Windows: NSIS setup.exe, silent install ──────────────────────────────
windows:
# See the macOS job: a fork PR's token cannot list drafts, so skip rather than fail.
if: github.event.pull_request.head.repo.fork != true
name: desktop windows
runs-on: windows-latest
# 60, not 45: this job runs the bundled installer, and a full torch install on a
# Windows runner is the slowest of the three platforms.
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download the shipped installer
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p dl logs
# See the macOS job. Loud on purpose: no bundle means nothing to prove.
if [ -z "$REL_TAG" ]; then
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \
--json tagName,createdAt \
--jq '[.[] | select(.tagName | startswith("desktop-v"))]
| sort_by(.createdAt) | reverse | .[0].tagName // empty')"
[ -n "$REL_TAG" ] || {
echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)"
exit 1
}
echo "resolved release tag: $REL_TAG"
echo "REL_TAG=$REL_TAG" >> "$GITHUB_ENV"
fi
gh release download "$REL_TAG" --repo "$REL_REPO" --pattern '*setup.exe' --dir dl
ls -la dl
- name: Strip the developer toolchain
# Same gate as macOS, for the same `inputs`-coercion reason.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
shell: pwsh
run: |
$drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\', 'CMake',
'Microsoft Visual Studio', 'BuildTools', 'LLVM', 'MSYS', 'mingw')
# winget is an app-execution alias under ...\Local\Microsoft\WindowsApps, so the
# WindowsApps fragment -- there to take the Store's python.exe alias away --
# drops the OS package manager with it. winget is not developer tooling: every
# consumer Windows machine this bundle ships to has it, and the bundled
# install.ps1 reaches for it for the git that studio/setup.ps1:1657-1669 still
# gates on unconditionally. Without it this lane only re-runs, as a hard failure,
# the no-winget fallback clean-machine-install-ci.yml already covers and pins on
# its winget=masked row. Resolve winget before the scrub and hand it back through
# a shim, exactly as that workflow does.
$wingetCmd = Get-Command winget -ErrorAction SilentlyContinue
if (-not $wingetCmd) {
Write-Host '::error::winget was not on PATH before the strip; this image ships it and the bundled installer needs it'
exit 1
}
$shim = Join-Path $env:RUNNER_TEMP 'winget-shim'
New-Item -ItemType Directory -Force -Path $shim | Out-Null
Set-Content -LiteralPath (Join-Path $shim 'winget.cmd') -Encoding ascii `
-Value "@`"$($wingetCmd.Source)`" %*"
$scrub = {
param($entries)
,@($entries | Where-Object { $p = $_; $p -and -not ($drop | Where-Object { $p -like "*$_*" }) })
}
"PATH=$shim;$((& $scrub ($env:PATH -split ';')) -join ';')" |
Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Off disk, not just off PATH: py.exe lives in C:\Windows (which must stay) and
# uv does its own discovery, so both reach the toolcache whatever PATH says.
foreach ($tc in @("$env:AGENT_TOOLSDIRECTORY\Python", 'C:\hostedtoolcache\windows\Python')) {
if ($tc -and (Test-Path $tc)) {
try { Rename-Item -LiteralPath $tc -NewName 'Python.masked' -ErrorAction Stop
Write-Host "masked toolcache python: $tc" }
catch { Write-Host "::error::could not mask $tc ($($_.Exception.Message)); the job would not be clean"; exit 1 }
}
}
# The bundled install.ps1 this job runs calls Refresh-SessionPath (318-337), which
# merges the Machine and User registry PATHs back into $env:Path, so a
# process-only scrub lasts until the first refresh and Git/CMake/VS/LLVM come back
# from the registry. The runner is ephemeral, so rewrite the registry copies too.
# (A merge keeps what the process already had, which is why the winget shim above
# survives.) Expand first: SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ
# (dotnet/runtime#1442).
foreach ($scope in 'Machine','User') {
$raw = [System.Environment]::GetEnvironmentVariable('Path', $scope)
if ([string]::IsNullOrWhiteSpace($raw)) { continue }
$expanded = [System.Environment]::ExpandEnvironmentVariables($raw) -split ';'
try {
[System.Environment]::SetEnvironmentVariable('Path', ((& $scrub $expanded) -join ';'), $scope)
} catch {
Write-Host "::error::could not scrub the $scope PATH ($($_.Exception.Message)); the strip would not survive Refresh-SessionPath"
exit 1
}
}
foreach ($v in 'VSINSTALLDIR','VCINSTALLDIR','WindowsSdkDir','INCLUDE','LIB','LIBPATH') {
"$v=" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
exit 0
- name: Verify the strip took effect
# PATH written to $GITHUB_ENV only applies to LATER steps, so the scrub can only be
# checked from here. The drop list above is heuristic path-fragment matching: if a
# runner image moves any of these tools outside those fragments, the bundled
# install.ps1 reuses the survivor and this job still calls itself clean. Same
# assertion the installer workflow runs, same reason.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
shell: pwsh
run: |
$leaked = @()
foreach ($t in 'python','py','git','cmake','cl') {
$f = Get-Command $t -ErrorAction SilentlyContinue
Write-Host ("{0,-8} {1}" -f $t, $(if ($f) { $f.Source } else { 'ABSENT' }))
if ($f -and $t -ne 'py') { $leaked += "$t -> $($f.Source)" }
}
# `py` itself lives in C:\Windows and stays. Only an interpreter it can still
# START is a leak, because Find-CompatiblePython (install.ps1:1130-1153) probes
# `py` first. `py -0p` is only the launcher's REGISTRY view, which still names the
# paths the rename removed, so a start attempt is the only real evidence.
if (Get-Command py -ErrorAction SilentlyContinue) {
foreach ($v in '-3.11', '-3.12', '-3.13') {
$out = & py $v -c "import sys; print(sys.executable)" 2>&1
$rc = $LASTEXITCODE
Write-Host ("py {0} -> exit {1}: {2}" -f $v, $rc, (($out | Out-String).Trim() -replace '\r?\n', ' / '))
if ($rc -eq 0) { $leaked += "py $v -> $out" }
}
# A failing probe is the outcome we want, but it leaves $LASTEXITCODE non-zero
# and the runner appends `exit $LASTEXITCODE` to every pwsh step
# (actions/runner#351), so the step would exit 1 on a machine that is clean.
$global:LASTEXITCODE = 0
}
# The shim is the only reason winget resolves after the WindowsApps drop. It
# survives the installer's own refreshes because Refresh-SessionPath
# (install.ps1:318-337) and setup.ps1's Refresh-Environment MERGE the current
# $env:Path back in rather than replace it -- but assert that, or this lane
# silently degrades into the no-winget leg the installer workflow already pins.
$winget = Get-Command winget -ErrorAction SilentlyContinue
Write-Host ("winget {0}" -f $(if ($winget) { $winget.Source } else { 'ABSENT' }))
if (-not $winget) {
Write-Host '::error::winget did not survive the strip; the bundled installer would take the no-winget fallback instead of the consumer path'
exit 1
}
if ($leaked) {
Write-Host "::error::developer tooling survived the strip: $($leaked -join '; ')"
exit 1
}
exit 0
- name: Silent install
shell: pwsh
run: |
$exe = (Get-ChildItem dl/*setup.exe | Select-Object -First 1).FullName
# /S is the NSIS silent switch: a user double-clicks, but an installer that cannot
# run unattended cannot be scripted or MDM-deployed either.
$p = Start-Process -FilePath $exe -ArgumentList '/S' -Wait -PassThru
Write-Host "installer exit: $($p.ExitCode)"
if ($p.ExitCode -ne 0) { Write-Host "::error::silent install failed"; exit 1 }
$found = Get-ChildItem -Path "$env:LOCALAPPDATA","$env:ProgramFiles" -Recurse `
-Filter '*Unsloth*.exe' -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $found) { Write-Host '::error::no installed executable found'; exit 1 }
Write-Host "installed: $($found.FullName)"
"APP_EXE=$($found.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Run the bundled installer, the path first launch takes
shell: pwsh
run: |
# The launch step below only proves the process stayed alive: on a fresh profile
# the app waits for a click on Install (use-tauri-backend.ts:252-254,
# startup-screen.tsx:388-389), so this job passed on a bundle whose embedded
# install.ps1 was missing or broken. tauri.conf.json:56-59 ships it as a bundle
# resource, so find it where NSIS put it and invoke it as install.rs:326-341.
$root = Split-Path -Parent $env:APP_EXE
$ps1 = Get-ChildItem -Path $root -Recurse -Filter 'install.ps1' -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $ps1) {
Write-Host '::error::the bundle ships no install.ps1 resource'
exit 1
}
Write-Host "bundled installer: $($ps1.FullName)"
# --tauri rejects a custom studio home (install.ps1:189-215), so drop the
# override as install.rs does (354-357).
Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue
& powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass `
-File $ps1.FullName --tauri *>&1 | Tee-Object -FilePath logs/bundled-install.log
$rc = $LASTEXITCODE
Write-Host "bundled installer exit: $rc"
if ($rc -ne 0) {
Write-Host "::error::bundled installer exited $rc"
exit $rc
}
# The exit code alone is not enough: it is the venv the app then boots from.
$py = Join-Path $env:USERPROFILE '.unsloth\studio\unsloth_studio\Scripts\python.exe'
if (-not (Test-Path $py)) {
Write-Host "::error::bundled installer left no venv at $py"
exit 1
}
& $py -V
# install.rs passes only --tauri, so torch is part of first launch, and a venv that
# cannot import it is the unbootable environment from the report.
& $py -c "import torch; print('torch', torch.__version__)"
if ($LASTEXITCODE -ne 0) {
Write-Host '::error::the bundled install produced a venv with no working torch'
exit 1
}
- name: Launch and prove it stays up
shell: pwsh
run: |
$p = Start-Process -FilePath $env:APP_EXE -PassThru `
-RedirectStandardOutput logs/app-stdout.log `
-RedirectStandardError logs/app-stderr.log
for ($i = 0; $i -lt 90; $i++) { if ($p.HasExited) { break }; Start-Sleep -Seconds 1 }
if ($p.HasExited) {
Write-Host "::error::desktop app exited early with rc=$($p.ExitCode)"
Get-Content logs/app-stdout.log, logs/app-stderr.log -Tail 40 -ErrorAction SilentlyContinue
exit 1
}
Write-Host "app still running after 90s"
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
- name: What did its own log say?
if: always()
shell: pwsh
run: |
$found = $false; $disposition = $false
foreach ($f in @("$env:UNSLOTH_STUDIO_HOME\tauri.log",
"$env:USERPROFILE\.unsloth\studio\tauri.log")) {
if (Test-Path $f) {
Write-Host "=== $f ==="
Copy-Item $f logs/ -ErrorAction SilentlyContinue
Get-Content $f -Tail 60
Select-String -Path $f -Pattern 'disposition=|can_auto_repair=|ModuleNotFoundError' `
-ErrorAction SilentlyContinue
$found = $true
if (Select-String -Path $f -Pattern 'desktop_preflight completed disposition=' `
-SimpleMatch -Quiet) { $disposition = $true }
}
}
# Same acceptance criterion macOS and Linux enforce: Test-Path, Get-Content and
# Select-String cannot fail, so without these two lines the step was decoration.
if (-not $found) {
Write-Host '::error::the app wrote no tauri.log; it never reached setup_logging'
exit 1
}
if (-not $disposition) {
Write-Host '::error::tauri.log records no desktop_preflight disposition; the app never completed preflight'
exit 1
}
exit 0
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-windows
path: logs/
retention-days: 7
if-no-files-found: warn

View file

@ -2761,6 +2761,37 @@ exit 0
}
}
# ── CI only: overlay a source checkout over the package just installed ──
# Mirrors install.sh. Not a consumer knob: no switch, absent from the usage
# text, ignored unless UNSLOTH_CI_SOURCE_OVERLAY names a directory holding a
# pyproject.toml.
#
# The clean-machine legs run THIS script from a branch but install unsloth
# from PyPI, the consumer path, so everything Python-side (studio/setup.ps1,
# install_python_stack.py and every requirements/constraints file they reach
# via Path(__file__)) would be the released wheel's and a branch could not be
# validated. `& $UnslothExe studio setup` below goes through the CLI, and an
# editable overlay makes _PACKAGE_ROOT in unsloth_cli/commands/studio.py
# resolve to the working tree by PEP 660 __file__, so setup.ps1 comes from
# this ref. NOT --local: that also installs `unsloth-zoo @
# git+https://github.com/unslothai/unsloth-zoo`, which genuinely needs git,
# and git absence is what the masked leg proves; editable + --no-deps
# resolves and clones nothing, so it survives git, cmake and MSVC all missing.
if ($env:UNSLOTH_CI_SOURCE_OVERLAY) {
$CiOverlayRoot = $env:UNSLOTH_CI_SOURCE_OVERLAY
if (-not (Test-Path -LiteralPath (Join-Path $CiOverlayRoot "pyproject.toml"))) {
Write-Host "[ERROR] UNSLOTH_CI_SOURCE_OVERLAY is set to '$CiOverlayRoot' but there is no pyproject.toml there." -ForegroundColor Red
return (Exit-InstallFailure "UNSLOTH_CI_SOURCE_OVERLAY has no pyproject.toml: $CiOverlayRoot")
}
substep "CI: overlaying source checkout (editable, no deps): $CiOverlayRoot"
# Retry: the editable build fetches its build backend from PyPI, same
# transient-network risk as every other step.
$CiOverlayExit = Invoke-InstallCommandRetry -Label "overlay CI source checkout" -Command { uv pip install --python $VenvPython --no-deps -e $CiOverlayRoot }
if ($CiOverlayExit -ne 0) {
return (Exit-InstallFailure "Failed to overlay the CI source checkout (exit code $CiOverlayExit)" $CiOverlayExit)
}
}
# ── Run studio setup ──
# setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools,
# CUDA Toolkit, and other dependencies automatically via winget. Node.js is

View file

@ -4185,6 +4185,32 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then
fi
fi
# ── CI only: overlay a source checkout over the package just installed ──
# Not a consumer knob: no flag, absent from --help, ignored unless
# UNSLOTH_CI_SOURCE_OVERLAY names a directory holding a pyproject.toml.
#
# The clean-machine legs run THIS script from a branch but install unsloth from
# PyPI, the consumer path, so everything Python-side (studio/setup.sh, setup.ps1,
# install_python_stack.py and every requirements/constraints file they reach via
# Path(__file__)) would be the released wheel's and a branch could not be
# validated. An editable overlay re-points `import studio` at the working tree, so
# the importlib.resources lookup below finds this ref's setup.sh. NOT --local:
# that also installs `unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo`,
# which genuinely needs git, and git absence is what these legs prove; editable +
# --no-deps resolves and clones nothing, so it survives git, cmake and the C/C++
# compilers all being gone.
if [ -n "${UNSLOTH_CI_SOURCE_OVERLAY:-}" ]; then
if [ ! -f "$UNSLOTH_CI_SOURCE_OVERLAY/pyproject.toml" ]; then
echo "[ERROR] UNSLOTH_CI_SOURCE_OVERLAY is set to '$UNSLOTH_CI_SOURCE_OVERLAY' but there is no pyproject.toml there." >&2
exit 1
fi
substep "CI: overlaying source checkout (editable, no deps): $UNSLOTH_CI_SOURCE_OVERLAY"
# Retry: the editable build fetches its build backend from PyPI, same
# transient-network risk as every other install step.
run_install_cmd_retry "overlay CI source checkout" uv pip install --python "$_VENV_PY" \
--no-deps -e "$UNSLOTH_CI_SOURCE_OVERLAY"
fi
# ── Run studio setup ──
tauri_log "STEP" "Running Unsloth setup"
# When --local, use the repo's own setup.sh directly.

View file

@ -2790,6 +2790,9 @@ def pip_install_try(
env = _install_env_for_cmd(cmd),
)
if result.returncode == 0:
# As pip_install below: `nobuild` only catches a build that reaches the log.
if VERBOSE and result.stdout:
print(_redact_install_output(result.stdout))
return True
if VERBOSE and result.stdout:
# pip/uv echo index URLs (credentials included) in failure output.
@ -2845,6 +2848,15 @@ def pip_install(
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode == 0:
# Echo success under UNSLOTH_VERBOSE, as install.sh's
# run_install_cmd does. Without it the dependency phase never
# reached the install log that clean-machine-assert.sh's `nobuild`
# greps for uv's "Building <pkg>==<ver>", so a source build in this
# step -- the studio.txt install, where sdist-only dependencies
# actually show up -- reported "built: none" and stayed green.
# Redacted: uv echoes index URLs with credentials.
if VERBOSE and result.stdout:
print(_redact_install_output(result.stdout))
return
print(_red(f" uv failed, falling back to pip..."))
if result.stdout: