Skip to content

Commit b6781a8

Browse files
CI: prove the installer works on a machine with no developer toolchain (#7551)
* 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * 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. * Make the WSL job gate, assert Windows installed no toolchain, strip before the .deb * Assert the root Linux legs did not compile llama.cpp with the apt-installed toolchain * 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. * 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. * 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. * Gate the no-elevation Linux install and split off the no-transport case * Assert no source build on the hosted Windows legs and keep winget for the desktop lane * Retry the container root CA seeding instead of failing on one Windows Update timeout * Run the clean-machine workflow for the prebuilt installer helpers it overlays * Narrow the container pin to its own gates and scan uv and the venv interpreter for arch * Tighten the clean-machine comments * Re-assert toolchain absence after the desktop .deb pulls its dependencies * 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. * 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. * Clean machine CI: strip Strawberry, make the Fedora pin gating, run the Linux CLI desktop windows failed the strip verification because windows-latest ships a MinGW toolchain under C:\Strawberry\c\bin, which matches none of the drop fragments; the installer workflow already scrubs it. Fedora sat behind job-level continue-on-error, so its outcome pin could not fail the run. Tolerate the install step instead, as the no-transport row does. The Linux usable-install check only tested the executable bit; Windows and WSL already execute the CLI. The macho scan now fails when no venv interpreter was scanned, rather than letting uv alone satisfy the outside-root guard. * Clean machine CI: tighten the comments Round 12 comment reduction: compress wording, keep every reason. Comments only, verified with a YAML leaf walk (differences only inside run: scalars, only on # lines), bash --pretty-print -n byte comparison, a PowerShell token-stream diff and a Python AST comparison. * Clean machine CI: dereference the venv interpreter, pin the deb deps and the Windows disposition file did not follow the <venv>/bin/python symlink find -L printed, so it answered 'symbolic link to ...' and the Mach-O test dropped the one interpreter the Rosetta scan exists to check. Read with file -Lb and count what was classified, not what was found. apt treats a toolchain package the strip only renamed as already installed, so a .deb that started declaring git or cmake would never restore it and the absent re-check would still pass. Assert the declared Depends instead. The Windows lane accepted any preflight disposition although the bundled installer was already required to build a working venv; NotInstalled or ManagedStale there means the app cannot boot what it just installed. * Clean machine CI: assert every masked tool, and re-select the developer dir last clean-machine-env.sh moves ten tools aside and only warns when a move fails, but absent checked four of them, so a surviving gcc -- which install.sh probes for build-essential -- went unnoticed. restore.sh ran xcode-select --switch before the line that moved CommandLineTools back, so it named a still-masked directory, failed into || true and left the selection link unrestored. Capture the original selection and re-apply it after both directory restores. --------- Co-authored-by: danielhanchen <unslothai@gmail.com>
1 parent a114fec commit b6781a8

11 files changed

Lines changed: 3425 additions & 0 deletions

.github/scripts/assert-nobuild.ps1

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# SPDX-License-Identifier: AGPL-3.0-only
2+
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
3+
4+
# The `nobuild` contract from clean-machine-assert.sh, for Windows. A port, not
5+
# `shell: bash`: the scrub drops every `*\Git\*` PATH entry the bash version needs
6+
# sed/grep/tr/sort from, and it also runs inside the servercore container, which has no
7+
# bash. Both Windows lanes call this one file so the sdist allowlist cannot drift.
8+
#
9+
# Usage: assert-nobuild.ps1 -LogPath logs/install.log (exit 1 = a source build)
10+
[CmdletBinding()]
11+
param([Parameter(Mandatory = $true)][string] $LogPath)
12+
13+
if (-not (Test-Path -LiteralPath $LogPath)) {
14+
Write-Host "::error::nobuild requested but $LogPath is missing"
15+
exit 1
16+
}
17+
18+
# "Built an sdist" is NOT "needed a compiler": each name was verified against its own
19+
# sdist (setuptools.build_meta, no ext_modules, no .c/.cpp/.pyx/.rs), so its PEP 517
20+
# build is a pure-Python copy step. clean-machine-assert.sh carries the per-name detail.
21+
$allow = @('openai-whisper', 'argbind', 'randomname', 'antlr4-python3-runtime', 'triton-kernels')
22+
if ($env:UNSLOTH_ALLOW_SDIST) {
23+
$allow += ($env:UNSLOTH_ALLOW_SDIST -split '\s+' | Where-Object { $_ })
24+
}
25+
# Lowercased and underscore-folded on both sides: the distribution name and the name uv
26+
# prints can differ on the separator (triton_kernels vs triton-kernels).
27+
$allow = @($allow | ForEach-Object { $_.ToLowerInvariant() -replace '_', '-' })
28+
29+
# [char]27, not "`e": that escape is PowerShell 6+ and degrades to a literal "e" under
30+
# 5.1, so the strip would eat real text instead of ANSI codes.
31+
$esc = [char]27
32+
$text = (Get-Content -LiteralPath $LogPath -Raw) -replace "$esc\[[0-9;]*[A-Za-z]", ''
33+
$built = @()
34+
foreach ($line in ($text -split "`r?`n")) {
35+
# A local-path build is one the caller pointed at (the overlay), never one
36+
# resolution chose; index deps always print `==<version>`.
37+
if ($line -imatch 'building [a-z0-9._-]+ @ file://') { continue }
38+
# pip prints `Building wheel for <pkg>`, uv `Building <pkg>==<ver>`
39+
# (astral-sh/uv#11165); the `==` / ` @ ` keeps this off the installer's own
40+
# "building frontend..." text.
41+
foreach ($m in [regex]::Matches($line, '(?i)building wheel for ([a-z0-9._-]+)|building ([a-z0-9._-]+)(==| @ )')) {
42+
$name = if ($m.Groups[1].Success) { $m.Groups[1].Value } else { $m.Groups[2].Value }
43+
$built += ($name.ToLowerInvariant() -replace '_', '-')
44+
}
45+
}
46+
$built = @($built | Sort-Object -Unique)
47+
$bad = @($built | Where-Object { $allow -notcontains $_ })
48+
49+
$rc = 0
50+
if ($bad.Count -gt 0) {
51+
Write-Host "::error::built from source: $($bad -join ' ') -- these must resolve to wheels on a clean machine"
52+
$rc = 1
53+
} else {
54+
Write-Host "[assert] OK no non-allowlisted source build (built: $(if ($built) { $built -join ' ' } else { 'none' }))"
55+
}
56+
# Independent of package names: a compiler error means a toolchain was needed.
57+
$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'
58+
if ($compilerErr) {
59+
Write-Host '::error::compiler invocation appears in the install log'
60+
$compilerErr | Select-Object -First 10 | ForEach-Object { Write-Host " $($_.Line)" }
61+
$rc = 1
62+
}
63+
exit $rc
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: AGPL-3.0-only
3+
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
4+
#
5+
# Assert the clean-machine contract after an install attempt.
6+
#
7+
# absent The toolchain really was absent for the whole run. Catches a leg that
8+
# "passed" because masking silently failed, or because the installer
9+
# quietly installed Xcode CLT behind our back.
10+
# notools The trace recorded no compiler/git/brew invocation (trace mode).
11+
# nobuild Wheels-only: no "Building wheel" from pip, no "Building <pkg>==<ver>"
12+
# from uv. Needs UNSLOTH_VERBOSE=1, or run_install_cmd
13+
# (install.sh:193-243) discards uv's output on success.
14+
# macho Every Mach-O under $MACHO_ROOT is the host architecture, and every
15+
# Mach-O MAIN EXECUTABLE is signed. Closes the Rosetta 2 gap, the one
16+
# divergence masking cannot reproduce.
17+
#
18+
# Usage: bash .github/scripts/clean-machine-assert.sh absent notools nobuild macho
19+
set -uo pipefail
20+
21+
LOG="${INSTALL_LOG:-logs/install.log}"
22+
TRACE="${UNSLOTH_TOOL_TRACE:-}"
23+
rc=0
24+
25+
fail() { echo "::error::$*"; rc=1; }
26+
ok() { echo "[assert] OK $*"; }
27+
28+
for check in "$@"; do
29+
case "$check" in
30+
31+
absent)
32+
# NOT `command -v`: on a virgin Mac /usr/bin/{git,cc} EXIST as CLT stubs, so it
33+
# succeeds and only RUNNING them fails. The invariant is: must not WORK.
34+
if xcode-select -p >/dev/null 2>&1; then
35+
fail "xcode-select -p still resolves to $(xcode-select -p 2>/dev/null); not a clean Mac"
36+
else
37+
ok "xcode-select -p fails (the gate a virgin Mac hits)"
38+
fi
39+
# The whole set clean-machine-env.sh moves aside, not the four it used to check: that
40+
# helper warns and carries on when a move fails, so a surviving gcc -- which
41+
# install.sh probes to decide build-essential is available -- passed unnoticed.
42+
for tool in git cc clang cmake gcc g++ make ninja cargo rustc; do
43+
command -v "$tool" >/dev/null 2>&1 || { ok "$tool not on PATH"; continue; }
44+
if "$tool" --version >/dev/null 2>&1; then
45+
# Intel runners' /usr/bin/git is not CLT-provided, so masking cannot take it
46+
# away; cc/clang do become stubs and macOS needs no git, so report, not fail.
47+
case " ${UNSLOTH_CLEAN_ALLOW_WORKING:-} " in
48+
*" $tool "*)
49+
echo "[assert] NOTE $tool still works ($(command -v "$tool")); allowed on this runner"
50+
continue
51+
;;
52+
esac
53+
fail "toolchain still usable: '$tool --version' succeeded ($(command -v "$tool")); masking failed"
54+
else
55+
ok "$tool present but non-functional (CLT stub), as on a clean Mac"
56+
fi
57+
done
58+
# brew is a plain binary with no stub, so absence from PATH is the right test.
59+
if command -v brew >/dev/null 2>&1; then
60+
fail "Homebrew still on PATH at $(command -v brew); masking failed"
61+
else
62+
ok "brew absent"
63+
fi
64+
;;
65+
66+
notools)
67+
if [ -z "$TRACE" ] || [ ! -f "$TRACE" ]; then
68+
fail "notools requested but no trace file (\$UNSLOTH_TOOL_TRACE=$TRACE)"
69+
else
70+
# git is legitimate under --local (unsloth-zoo comes from a git URL), so that
71+
# leg allow-lists it via UNSLOTH_ALLOW_TOOLS.
72+
allow="${UNSLOTH_ALLOW_TOOLS:-}"
73+
hits=""
74+
while IFS=$'\t' read -r tool rest; do
75+
[ -n "$tool" ] || continue
76+
case " $allow " in *" $tool "*) continue ;; esac
77+
# `xcode-select -p` only ASKS whether a toolchain is selected and the fix is
78+
# carrying on without one, so it is not USE. `--install` stays a hit.
79+
if [ "$tool" = "xcode-select" ]; then
80+
case "$rest" in
81+
-p|--print-path|-v|--version|"") continue ;;
82+
esac
83+
fi
84+
hits="$hits $tool"
85+
done < "$TRACE"
86+
if [ -n "$hits" ]; then
87+
fail "installer invoked toolchain:$(echo "$hits" | tr ' ' '\n' | sort -u | tr '\n' ' ')"
88+
echo "---- tool trace ----"; sort -u "$TRACE" | head -50
89+
else
90+
ok "no compiler/git/brew invocation recorded"
91+
fi
92+
fi
93+
;;
94+
95+
nobuild)
96+
# "Built an sdist" is NOT "needed a compiler". Every name was verified against its
97+
# own sdist: setuptools.build_meta, no ext_modules, no .c/.cpp/.pyx/.rs, so the
98+
# PEP 517 build is a pure-Python copy step.
99+
# openai-whisper, argbind, randomname -- no version ever ships a wheel
100+
# antlr4-python3-runtime==4.9.3 -- pinned below the 4.13.2 wheel
101+
# triton-kernels -- a git URL under the triton repo's python/triton_kernels
102+
# subdir: 75 Python files, no setup.py, kernels compiled at runtime. Named by
103+
# the installer, not chosen by resolution, and only the Linux legs reach it.
104+
# UNSLOTH_ALLOW_SDIST extends it. Lowercased and underscore-folded on both sides:
105+
# the distribution name and the name uv prints can differ on the separator.
106+
_allow="$(printf '%s' "openai-whisper argbind randomname antlr4-python3-runtime triton-kernels ${UNSLOTH_ALLOW_SDIST:-}" | tr 'A-Z_' 'a-z-')"
107+
if [ ! -f "$LOG" ]; then
108+
fail "nobuild requested but $LOG is missing"
109+
else
110+
# uv prints `Building <name>==<ver>`, pip `Building wheel for <name>`
111+
# (astral-sh/uv#11165), so match both; the `==` / ` @ ` keeps this off the
112+
# installer's own "building frontend..." text, and ANSI is stripped so a
113+
# coloured run parses. `Building <name> @ file://` is dropped -- a local-path
114+
# build is one the caller pointed at (--local, the overlay), never one
115+
# resolution chose, while index deps always print `<name>==<ver>`, so a genuine
116+
# PyPI sdist is still caught, including one named unsloth.
117+
_esc=$(printf '\033')
118+
_built="$(sed -E "s/${_esc}\[[0-9;]*[A-Za-z]//g" "$LOG" 2>/dev/null \
119+
| grep -viE "building [a-z0-9._-]+ @ file://" \
120+
| grep -oiE "building wheel for [a-z0-9._-]+|building [a-z0-9._-]+(==| @ )" \
121+
| tr 'A-Z' 'a-z' \
122+
| sed -E -e 's/^building wheel for //' -e 's/^building //' -e 's/(==| @ )$//' \
123+
| tr '_' '-' \
124+
| sort -u || true)"
125+
_bad=""
126+
for pkg in $_built; do
127+
case " $_allow " in *" $pkg "*) continue ;; esac
128+
_bad="$_bad $pkg"
129+
done
130+
if [ -n "$_bad" ]; then
131+
fail "built from source:$_bad -- these must resolve to wheels on a clean machine"
132+
else
133+
[ -n "$_built" ] && say_built="$(echo "$_built" | tr '\n' ' ')" || say_built="none"
134+
ok "no non-allowlisted source build (built: $say_built)"
135+
fi
136+
# Independent of package names: a compiler error means a toolchain was needed.
137+
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
138+
fail "compiler invocation appears in the install log"
139+
grep -iE "error: command '(cc|gcc|clang|cl)' failed|clang: error" "$LOG" | head -10
140+
fi
141+
fi
142+
;;
143+
144+
macho)
145+
# The one thing masking cannot reproduce: Rosetta 2 ships on hosted runners and
146+
# not on a factory-fresh Mac, so an x86_64-only payload runs green here and dies
147+
# with "bad CPU type in executable" for the user. `lipo` is an xcrun shim and gone
148+
# after masking, so read `file -Lb`, keyed off `uname -m` (macos-15-intel is x86_64).
149+
#
150+
# SCOPE: all of $MACHO_ROOT, .venv_t5_510/_530/_550 sidecars included -- payload,
151+
# not scratch (setup.sh:579-581 creates them, transformers_version.py:338-348 puts
152+
# them on sys.path). Any exclusion must be a named path rule, never a narrowed find.
153+
root="${MACHO_ROOT:-${UNSLOTH_STUDIO_HOME:-$HOME/.unsloth}}"
154+
want="$(uname -m)"
155+
[ "$want" = "aarch64" ] && want=arm64
156+
if [ ! -d "$root" ]; then
157+
fail "macho requested but $root does not exist"
158+
else
159+
# SCOPE, part 2: the two payloads the install RUNS ON live outside $root. `uv
160+
# venv` links <venv>/bin/python at its base interpreter and the find below has no
161+
# -L, so the interpreter that ran every step is invisible to it; the uv that
162+
# fetched it lands in $HOME/.local/bin. Both are what Rosetta 2 hides: an x86_64
163+
# one runs green here and dies on the factory-fresh Mac this stands in for.
164+
# -L follows that symlink; -maxdepth keeps this a bin/ lookup, not a second walk
165+
# of site-packages through the venv's lib64 link -- depth 4 covers
166+
# <root>/unsloth_studio, the .venv_t5_* sidecars and <root>/studio/unsloth_studio.
167+
# In a variable so it can be counted separately: uv alone would satisfy $nout.
168+
base_py="$(find -L "$root" -maxdepth 4 -type f -path '*/bin/python' 2>/dev/null)"
169+
_macho_targets() {
170+
find "$root" -type f \( -perm -u+x -o -name '*.dylib' -o -name '*.so' -o -name '*.node' \) 2>/dev/null
171+
[ -n "$base_py" ] && printf '%s\n' "$base_py"
172+
for _uv in "$HOME/.local/bin/uv" "$(command -v uv 2>/dev/null || true)"; do
173+
[ -n "$_uv" ] && [ -f "$_uv" ] && printf '%s\n' "$_uv"
174+
done
175+
}
176+
n=0 nexe=0 nout=0 nbase=0 bad_arch="" unsigned="" broken=""
177+
while IFS= read -r f; do
178+
# -L: find printed the SYMLINK path for <venv>/bin/python, and plain `file` does
179+
# not dereference, so it answered "symbolic link to ..." and the Mach-O test
180+
# below dropped the very interpreter this scan exists to check.
181+
desc="$(file -Lb "$f" 2>/dev/null || true)"
182+
case "$desc" in *Mach-O*) ;; *) continue ;; esac
183+
n=$((n + 1))
184+
case "$f" in "$root"/*) ;; *) nout=$((nout + 1)) ;; esac
185+
# Classified, not merely found: an entry `file` could not read is invisible here.
186+
case "
187+
$base_py
188+
" in *"
189+
$f
190+
"*) nbase=$((nbase + 1)) ;; esac
191+
# Substring, not equality: a universal binary lists every slice it carries,
192+
# and one that includes the host arch is fine.
193+
case "$desc" in
194+
*"$want"*) ;;
195+
*) bad_arch="$bad_arch $f [$desc]" ;;
196+
esac
197+
198+
# Signature: MAIN EXECUTABLES ONLY. Asserting it on every Mach-O failed the
199+
# mask/pipe leg on 29 ordinary PyPI extension modules plus libportaudio.dylib:
200+
# MH_BUNDLE/MH_DYLIB images dlopen'd without library validation ship unsigned,
201+
# and that run had already imported them with the installer exiting 0. macOS
202+
# enforces on main executables and gatekept .app bundles. Key off the filetype
203+
# `file` reports, not the path (a .so may be either); the library veto is second
204+
# so a mixed-type fat file counts as a library, and substring tests are
205+
# order-independent (Apple prints `executable arm64`, GNU `arm64 executable`).
206+
_is_exe=0
207+
case "$desc" in *executable*) _is_exe=1 ;; esac
208+
case "$desc" in *"shared library"*|*bundle*) _is_exe=0 ;; esac
209+
# Named rule so a failure says which path matched; the filetype test covers it.
210+
case "$f" in *.app/Contents/MacOS/*) _is_exe=1 ;; esac
211+
[ "$_is_exe" = 1 ] && nexe=$((nexe + 1))
212+
213+
# arm64 only: the kernel refuses to exec an unsigned arm64 main binary
214+
# ("Killed: 9"); x86_64 execs it happily, so it is not the same defect.
215+
if [ "$want" = "arm64" ] && [ "$_is_exe" = 1 ]; then
216+
# Ad-hoc counts as signed (arm64 linkers seal ad-hoc by default): the test is
217+
# "has a verifying seal", not "has an identity", which spctl/--strict demand.
218+
if ! codesign -v "$f" >/dev/null 2>&1; then
219+
# Nothing to verify and a seal that does not match differ. Captured, not
220+
# piped: `codesign -dvv` exits non-zero on an unsigned file, and under
221+
# pipefail that would be the pipeline's status even on a match.
222+
_sig="$(codesign -dvv "$f" 2>&1 || true)"
223+
case "$_sig" in
224+
*"not signed at all"*) unsigned="$unsigned $f" ;;
225+
*) broken="$broken $f" ;;
226+
esac
227+
fi
228+
fi
229+
done < <(_macho_targets | sort -u)
230+
if [ "$n" = "0" ]; then
231+
# An empty scan reads exactly like a clean one, so a wrong root would pass.
232+
fail "no Mach-O found under $root; the arch/signature assertion proved nothing"
233+
elif [ "$nbase" = "0" ]; then
234+
fail "no */bin/python under $root was classified as Mach-O, so the venv's base interpreter went unchecked (found: ${base_py:-none})"
235+
elif [ "$nout" = "0" ]; then
236+
# install.sh always bootstraps uv into $HOME/.local/bin, so zero hits outside
237+
# $root means the extra scan matched nothing and uv's arch went unproven.
238+
fail "no Mach-O outside $root was scanned, so uv and the venv's base interpreter escaped the check"
239+
elif [ -n "$bad_arch" ]; then
240+
fail "Mach-O is not $want, so it runs here only under Rosetta 2, which a fresh Mac does not have:$bad_arch"
241+
elif [ -n "$unsigned" ]; then
242+
fail "unsigned Mach-O main executable, which arm64 macOS refuses to exec:$unsigned"
243+
elif [ -n "$broken" ]; then
244+
fail "Mach-O main executable carries a signature that does not verify:$broken"
245+
else
246+
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")"
247+
fi
248+
fi
249+
;;
250+
251+
*)
252+
fail "unknown check '$check'"
253+
;;
254+
esac
255+
done
256+
257+
exit "$rc"

0 commit comments

Comments
 (0)