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.
This commit is contained in:
danielhanchen 2026-07-29 06:33:16 +00:00
commit f09a1e71a9
11 changed files with 620 additions and 680 deletions

View file

@ -1,12 +1,11 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# The `nobuild` contract from clean-machine-assert.sh, for Windows.
#
# A port and not `shell: bash`: the clean-machine scrub drops every `*\Git\*` PATH
# entry, and the bash version needs sed/grep/tr/sort out of Git's usr/bin. It also runs
# inside the servercore container, which has no bash at all. Both Windows lanes call
# this one file so the sdist allowlist cannot drift.
# The `nobuild` contract from clean-machine-assert.sh, for Windows. A port and not
# `shell: bash`: the scrub drops every `*\Git\*` PATH entry and the bash version needs
# sed/grep/tr/sort out of Git's usr/bin, and it also runs inside the servercore
# container, which has no bash. Both Windows lanes call this one file so the sdist
# allowlist cannot drift.
#
# Usage: assert-nobuild.ps1 -LogPath logs/install.log (exit 1 = a source build)
[CmdletBinding()]
@ -17,9 +16,10 @@ if (-not (Test-Path -LiteralPath $LogPath)) {
exit 1
}
# "Built an sdist" is NOT "needed a compiler": every name here has a
# setuptools.build_meta backend, no ext_modules and no .c/.cpp/.pyx/.rs file, so its
# PEP 517 build is a pure-Python copy step. Identical to clean-machine-assert.sh.
# "Built an sdist" is NOT "needed a compiler": every name here was verified against its
# own sdist -- setuptools.build_meta backend, no ext_modules, no .c/.cpp/.pyx/.rs file
# -- so its PEP 517 build is a pure-Python copy step. Identical to
# clean-machine-assert.sh, which carries the per-name rationale.
$allow = @('openai-whisper', 'argbind', 'randomname', 'antlr4-python3-runtime', 'triton-kernels')
if ($env:UNSLOTH_ALLOW_SDIST) {
$allow += ($env:UNSLOTH_ALLOW_SDIST -split '\s+' | Where-Object { $_ })
@ -34,8 +34,8 @@ $esc = [char]27
$text = (Get-Content -LiteralPath $LogPath -Raw) -replace "$esc\[[0-9;]*[A-Za-z]", ''
$built = @()
foreach ($line in ($text -split "`r?`n")) {
# A local-path build is something the caller pointed at (the CI source overlay),
# never something resolution chose; index dependencies always print `==<version>`.
# 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

View file

@ -5,13 +5,12 @@
# Assert the clean-machine contract after an install attempt.
#
# absent The toolchain really was absent for the whole run. Catches a leg that
# "passed" only because masking silently failed, or because the installer
# "passed" because masking silently failed, or because the installer
# quietly installed Xcode CLT behind our back.
# notools The trace recorded no compiler/git/brew invocation (trace mode).
# nobuild The wheels-only contract: no "Building wheel" from pip, no
# "Building <pkg>==<ver>" from uv. Needs UNSLOTH_VERBOSE=1, or
# run_install_cmd (install.sh:193-243) discards uv's output on success
# and there is nothing to read.
# 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.
@ -40,9 +39,9 @@ for check in "$@"; do
for tool in git cc clang cmake; do
command -v "$tool" >/dev/null 2>&1 || { ok "$tool not on PATH"; continue; }
if "$tool" --version >/dev/null 2>&1; then
# On Intel runners /usr/bin/git is not CLT-provided and survives their
# removal, so no masking can take it away. cc and clang do become stubs and
# the macOS consumer path needs no git, so report rather than fail.
# On Intel runners /usr/bin/git is not CLT-provided, so no masking can take
# it away. cc and clang do become stubs and the macOS consumer path needs
# no git, so report rather than fail.
case " ${UNSLOTH_CLEAN_ALLOW_WORKING:-} " in
*" $tool "*)
echo "[assert] NOTE $tool still works ($(command -v "$tool")); allowed on this runner"
@ -74,7 +73,7 @@ for check in "$@"; do
[ -n "$tool" ] || continue
case " $allow " in *" $tool "*) continue ;; esac
# `xcode-select -p` only ASKS whether a toolchain is selected, and the fix
# is that the installer carries on without one, so the question is not USE.
# is that the installer carries on without one, so it is not USE.
# `--install`, which pops the CLT installer, stays a hit.
if [ "$tool" = "xcode-select" ]; then
case "$rest" in
@ -93,17 +92,16 @@ for check in "$@"; do
;;
nobuild)
# "Built an sdist" is NOT "needed a compiler". Every name below was checked
# "Built an sdist" is NOT "needed a compiler". Every name below was verified
# against its own sdist: setuptools.build_meta backend, no ext_modules, no
# .c/.cpp/.pyx/.rs file, so its PEP 517 build is a pure-Python copy step.
# openai-whisper, argbind, randomname -- no version ever ships a wheel
# antlr4-python3-runtime==4.9.3 -- pinned below the 4.13.2 wheel
# triton-kernels -- requirements/triton-kernels.txt pins a git URL under
# the triton repo's python/triton_kernels subdirectory: 75 Python files,
# a four-line pyproject.toml, no setup.py, kernels compiled at runtime.
# A direct URL the installer names itself, not something resolution
# chose, and only the Linux legs reach it (install_python_stack.py skips
# the step on Windows and macOS).
# triton-kernels -- requirements/triton-kernels.txt pins a git URL under the
# triton repo's python/triton_kernels subdir: 75 Python files, a four-line
# pyproject.toml, no setup.py, kernels compiled at runtime. A direct URL the
# installer names itself, not something resolution chose, and only the Linux
# legs reach it (install_python_stack.py skips it on Windows and macOS).
# UNSLOTH_ALLOW_SDIST extends the allowlist.
#
# Lowercased and underscore-folded on both sides: a distribution name and the
@ -116,10 +114,10 @@ for check in "$@"; do
# <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 something the
# caller pointed at (--local, or the editable overlay), never something
# resolution chose. Index dependencies always print `<name>==<version>`, so a
# genuine PyPI sdist is still caught, including one named unsloth.
# `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://" \
@ -154,10 +152,10 @@ for check in "$@"; do
# xcrun shim and gone after masking, so read `file -b`, keyed off `uname -m`
# (macos-15-intel is x86_64).
#
# SCOPE: all of $MACHO_ROOT, including the .venv_t5_510/_530/_550 sidecars.
# Those are payload, not scratch: setup.sh:579-581 creates them during a
# normal install and transformers_version.py:338-348 puts them on sys.path.
# Any exclusion must be a named path rule, never a narrowed find.
# SCOPE: all of $MACHO_ROOT, .venv_t5_510/_530/_550 sidecars included. Those are
# payload, not scratch: setup.sh:579-581 creates them during a normal install and
# transformers_version.py:338-348 puts them on sys.path. Any exclusion must be a
# named path rule, never a narrowed find.
root="${MACHO_ROOT:-${UNSLOTH_STUDIO_HOME:-$HOME/.unsloth}}"
want="$(uname -m)"
[ "$want" = "aarch64" ] && want=arm64
@ -165,18 +163,17 @@ for check in "$@"; do
fail "macho requested but $root does not exist"
else
# SCOPE, part 2: the two payloads the install RUNS ON live outside $root.
# `uv venv` links <venv>/bin/python at its base interpreter rather than
# copying it, and the find below has no -L, so the interpreter that executed
# every install step is invisible to it; the uv that fetched it lands in
# $HOME/.local/bin. Both are exactly what Rosetta 2 hides -- an x86_64 uv or
# managed CPython runs green here and dies on the factory-fresh Mac this job
# stands in for.
# `uv venv` links <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.
# 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"
@ -195,11 +192,11 @@ for check in "$@"; do
*) bad_arch="$bad_arch $f [$desc]" ;;
esac
# Signature: MAIN EXECUTABLES ONLY. Asserting it for every Mach-O failed the
# Signature: MAIN EXECUTABLES ONLY. Asserting it on every Mach-O failed the
# mask/pipe leg on 29 ordinary PyPI extension modules plus libportaudio.dylib:
# those are MH_BUNDLE/MH_DYLIB images dlopen'd without library validation and
# ship unsigned, and that run had already imported them with the installer
# exiting 0. macOS enforces on main executables and gatekept .app bundles.
# MH_BUNDLE/MH_DYLIB images dlopen'd without library validation, shipped
# unsigned, and that run had already imported them with the installer exiting
# 0. macOS enforces on main executables and gatekept .app bundles.
#
# Key off the filetype `file` reports, not the path: a .so may be a bundle or a
# dylib, and an executable may have no extension. The library veto is second so
@ -218,14 +215,14 @@ for check in "$@"; do
# ("Killed: 9"), while x86_64 execs it happily, so an unsigned x86_64
# payload is not the same defect.
if [ "$want" = "arm64" ] && [ "$_is_exe" = 1 ]; then
# Ad-hoc counts as signed: arm64 linkers seal ad-hoc by default, so the
# test is "has a seal that verifies", not "has an identity". `spctl` and
# Ad-hoc counts as signed: arm64 linkers seal ad-hoc by default, so the test
# is "has a seal that verifies", not "has an identity". `spctl` and
# `--strict` would demand an authority and reject ad-hoc.
if ! codesign -v "$f" >/dev/null 2>&1; then
# Nothing to verify and a seal that does not match mean different things.
# Captured, not piped into grep: `codesign -dvv` exits non-zero on an
# unsigned file, and under `pipefail` that status is what the pipeline
# returns even on a match.
# unsigned file, and under `pipefail` that is the pipeline's status even
# on a match.
_sig="$(codesign -dvv "$f" 2>&1 || true)"
case "$_sig" in
*"not signed at all"*) unsigned="$unsigned $f" ;;
@ -235,8 +232,8 @@ for check in "$@"; do
fi
done < <(_macho_targets | sort -u)
if [ "$n" = "0" ]; then
# An empty scan reads exactly like a clean one, so the check would pass on a
# wrong root and prove nothing.
# An empty scan reads exactly like a clean one, so a wrong root would pass
# and prove nothing.
fail "no Mach-O found under $root; the arch/signature assertion proved nothing"
elif [ "$nout" = "0" ]; then
# Same rule for the roots added above: install.sh always bootstraps uv into

View file

@ -98,8 +98,8 @@ if [ "$MODE" = "mask" ]; then
if [ "$REMOVE" = "1" ] && [ "$OS" = "Darwin" ]; then
# Best effort, each step independent and recorded in restore.sh so an
# `if: always()` step can put the runner back. xcode_select_link is what
# `xcode-select -p` reads, so removing it reproduces a virgin Mac's gate;
# `if: always()` step can put the runner back. `xcode-select -p` reads
# xcode_select_link, so removing it reproduces a virgin Mac's gate;
# `xcode-select --reset` is NOT enough, it can reselect a full Xcode.app.
if [ -e /var/db/xcode_select_link ]; then
if sudo rm -f /var/db/xcode_select_link 2>/dev/null; then
@ -120,9 +120,9 @@ if [ "$MODE" = "mask" ]; then
fi
fi
# Xcode.app must go too: with the link removed AND CommandLineTools moved,
# `xcode-select -p` still does not fail, it falls through to the image's Xcode
# bundle (observed: /Applications/Xcode_16.4.app/Contents/Developer), which
# re-arms /usr/bin/{git,cc}. A rename is instant whatever the bundle size.
# `xcode-select -p` still succeeds, falling through to the image's Xcode bundle
# (observed: /Applications/Xcode_16.4.app/Contents/Developer), which re-arms
# /usr/bin/{git,cc}. A rename is instant whatever the bundle size.
for app in /Applications/Xcode*.app; do
[ -d "$app" ] || continue
if sudo mv "$app" "${app}.masked" 2>/dev/null; then
@ -133,9 +133,9 @@ if [ "$MODE" = "mask" ]; then
fi
done
# /usr/local EXISTS on a factory-fresh Mac: a SIP-exempt firmlink, and empty. What
# is absent is its CONTENTS, /usr/local/bin included. So empty it rather than
# remove it. Runs before the Homebrew block below so /usr/local/Homebrew is stashed
# once, with one restore line, in the right order.
# is absent is its CONTENTS, /usr/local/bin included, so empty it rather than remove
# it. Before the Homebrew block below, so /usr/local/Homebrew is stashed once, with
# one restore line, in the right order.
if [ -d /usr/local ]; then
STASH="$WORK/usr-local"
mkdir -p "$STASH"
@ -175,9 +175,9 @@ if [ "$MODE" = "mask" ]; then
if [ "$REMOVE" = "1" ] && [ "$OS" = "Linux" ]; then
# A hosted Linux runner keeps git, gcc, cmake and make in /usr/bin, which the PATH
# scrub has to keep, so absence must be made real: move the resolved binaries
# aside (recorded in restore.sh). Versioned siblings like gcc-11 survive, but a
# consumer install invokes the unsuffixed names, which is what `absent` checks.
# scrub has to keep, so absence must be made real: move the resolved binaries aside
# (recorded in restore.sh). Versioned siblings like gcc-11 survive, but a consumer
# install invokes the unsuffixed names, which is what `absent` checks.
for tool in $TOOLS; do
# Repeat per tool: a runner can carry the same name in /usr/bin and
# /usr/local/bin, and moving only the first leaves the second on PATH.

View file

@ -4,14 +4,12 @@
# Waits for the Windows Docker daemon on a hosted runner, starting the service if
# it is installed but not running.
#
# Docker is installed on every windows-2022 runner image (runner-images installs it
# via Microsoft's install-docker-ce.ps1, without -HyperV, so the daemon serves
# WINDOWS containers) but it is not always already RUNNING when a job starts. A
# spike run died 21 seconds in with
# Docker is installed on every windows-2022 image (runner-images uses Microsoft's
# install-docker-ce.ps1 without -HyperV, so the daemon serves WINDOWS containers) but
# is not always RUNNING when a job starts: a spike run died 21s in with
# failed to connect to the docker API at npipe:////./pipe/docker_engine
# while a sibling job on a different runner was fine. Without this wait that flake
# reads as "Windows containers are not available on hosted runners", which is the
# wrong conclusion entirely.
# while a sibling job was fine. Without this wait that flake reads as "Windows
# containers are not available on hosted runners", the wrong conclusion entirely.
[CmdletBinding()]
param([int] $TimeoutMinutes = 5)
@ -36,8 +34,8 @@ while ($true) {
Start-Sleep -Seconds 5
}
# The failing `docker info` probes leave $LASTEXITCODE non-zero, and the runner
# appends `exit $LASTEXITCODE` to every pwsh step (actions/runner#351), so without
# this reset a successful wait still fails the step.
# The failing `docker info` probes leave $LASTEXITCODE non-zero and the runner appends
# `exit $LASTEXITCODE` to every pwsh step (actions/runner#351), so without this reset a
# successful wait still fails the step.
$global:LASTEXITCODE = 0
exit 0

View file

@ -1,9 +1,9 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Runs INSIDE a Windows container, after virgin-windows-probe.ps1 has proved the
# environment has no toolchain. Runs install.ps1 the way a real user on a bare
# Windows box would, then asserts the same things the hosted Windows leg asserts.
# Runs INSIDE a Windows container, after virgin-windows-probe.ps1 has proved there is no
# toolchain: install.ps1 the way a real user on a bare Windows box runs it, then the same
# assertions the hosted Windows leg makes.
[CmdletBinding()]
param(
@ -20,16 +20,16 @@ function Section($t) { Write-Host ""; Write-Host "=== $t ===" }
# ── Environment the installer needs to be non-interactive ─────────────────────
Section 'install environment'
# install.ps1:2885-2888 prompts `Start Unsloth Studio now? [Y/n]` when
# [Environment]::UserInteractive is true and stdin is not redirected. Both hold in a
# `docker exec` session, so without this the installer BLOCKS FOREVER on Read-Host
# and the job dies on timeout with no diagnosis.
# [Environment]::UserInteractive is true and stdin is not redirected -- both hold under
# `docker exec` -- so without this the installer BLOCKS FOREVER on Read-Host and the job
# dies on timeout with no diagnosis.
$env:UNSLOTH_SKIP_AUTOSTART = '1'
# install.ps1:254/258 joins $env:USERPROFILE with no null guard. Setting the install
# root explicitly also keeps the container's state entirely under one directory.
# install.ps1:254/258 joins $env:USERPROFILE with no null guard. An explicit root also
# keeps the container's state under one directory.
$env:UNSLOTH_STUDIO_HOME = 'C:\studio-home'
$env:UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK = '1'
# Without this, uv's output is discarded on success and the nobuild check below can
# only ever report "built: none".
# Without this uv's output is discarded on success and the nobuild check below can only
# ever report "built: none".
$env:UNSLOTH_VERBOSE = '1'
if ($Overlay) {
$env:UNSLOTH_CI_SOURCE_OVERLAY = $Overlay
@ -73,8 +73,7 @@ Section 'assert: the install produced something usable'
if ($rc -ne 0) {
$failures += "installer exited $rc"
} else {
# Mirrors the Linux leg's "Assert the install is actually usable": an installer
# that exits 0 having done nothing must not pass.
# As the Linux leg: an installer that exits 0 having done nothing must not pass.
if (-not (Test-Path -LiteralPath $venvPy)) {
$failures += "installer exited 0 but left no managed Python at $venvPy"
Get-ChildItem -Path $env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue | Format-Table | Out-String | Write-Host
@ -89,11 +88,10 @@ if ($rc -ne 0) {
}
Section 'assert: torch imports'
# On the hosted runner this proves less than it looks like: the runner image ships
# the VC++ 2015-2022 runtime in System32, so Test-VCRedistInstalled (setup.ps1:875)
# short-circuits before it needs winget. THIS container is the first environment in
# which that is not true, so a failure here is a genuine finding about bare Windows,
# not a CI artefact.
# On the hosted runner this proves less than it looks: the image ships the VC++
# 2015-2022 runtime in System32, so Test-VCRedistInstalled (setup.ps1:875)
# short-circuits before it needs winget. THIS container is the first environment where
# that is not true, so a failure here is a genuine finding about bare Windows.
if (Test-Path -LiteralPath $venvPy) {
foreach ($dll in 'vcruntime140.dll', 'vcruntime140_1.dll', 'msvcp140.dll') {
$p = Join-Path $env:WINDIR "System32\$dll"
@ -111,9 +109,9 @@ if (Test-Path -LiteralPath $venvPy) {
Section "assert: the installer took the no-winget path"
if (Test-Path -LiteralPath $LogPath) {
# install.ps1:1098, the no-winget branch. A container has no Microsoft Store and
# therefore no App Installer, so this is the fallback path (python.org + astral.sh)
# under test -- the whole reason a container is a good harness.
# install.ps1:1098, the no-winget branch. A container has no Microsoft Store and so
# no App Installer, which puts the fallback path (python.org + astral.sh) under
# test -- the whole reason a container is a good harness.
$noWinget = 'will require Python + uv to be already installed'
if (Select-String -Path $LogPath -Pattern $noWinget -SimpleMatch -Quiet) {
Write-Host "confirmed: installer reported winget as unavailable and used the fallback path"
@ -132,8 +130,8 @@ if ($Overlay -and $rc -eq 0) {
}
Section 'assert: no non-allowlisted source build'
# Shared with the hosted Windows legs so the sdist allowlist lives in one place; the
# script prints its own diagnosis, so only the verdict is folded in here.
# Shared with the hosted Windows legs so the sdist allowlist lives in one place; it
# prints its own diagnosis, so only the verdict is folded in here.
$nobuild = Join-Path $PSScriptRoot 'assert-nobuild.ps1'
if (-not (Test-Path -LiteralPath $nobuild)) {
$failures += "assert-nobuild.ps1 is missing next to this script, so the no-build contract went unchecked"

View file

@ -1,14 +1,12 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Runs INSIDE a Windows container. Proves the environment is genuinely virgin
# BEFORE anything is installed into it.
#
# This script is the entire point of the container lane. The hosted-runner Windows
# legs of clean-machine-install-ci.yml simulate absence (rename the toolcache Python
# directory, scrub the Machine and User registry PATH); this one asserts real
# absence on an OS image that never had a toolchain. If these assertions do not
# run, the lane proves nothing that the masked legs did not already prove.
# Runs INSIDE a Windows container, proving the environment is genuinely virgin BEFORE
# anything is installed into it. This is the entire point of the container lane: the
# hosted-runner Windows legs of clean-machine-install-ci.yml only simulate absence
# (rename the toolcache Python dir, scrub the Machine and User registry PATH), while
# this asserts real absence on an image that never had a toolchain. Without it the lane
# proves nothing the masked legs did not already prove.
$ErrorActionPreference = 'Continue'
$failures = @()
@ -45,10 +43,9 @@ Write-Host "USERPROFILE : $env:USERPROFILE"
Write-Host "LOCALAPPDATA : $env:LOCALAPPDATA"
Write-Host "PROCESSOR_ARCH : $env:PROCESSOR_ARCHITECTURE"
# install.ps1 line 254/258 does Join-Path $env:USERPROFILE ".unsloth\studio" with no
# null guard, so an unset USERPROFILE aborts under ErrorActionPreference=Stop.
# The lane sets UNSLOTH_STUDIO_HOME, but record whether a bare container would have
# survived without it.
# install.ps1:254/258 does Join-Path $env:USERPROFILE ".unsloth\studio" with no null
# guard, so an unset USERPROFILE aborts under ErrorActionPreference=Stop. The lane sets
# UNSLOTH_STUDIO_HOME, but record whether a bare container would have survived without.
if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
Write-Host "::warning::USERPROFILE is unset in this container; install.ps1's default install root would abort"
}
@ -66,27 +63,26 @@ foreach ($t in $mustBeAbsent) {
}
Section 'informational: present but not a developer toolchain'
# These are OS components, not a toolchain. curl.exe and tar.exe ship in System32 on
# Server 2022 and are the only transport into a container with no git; naming them
# keeps the premise honest rather than silently relying on them.
# OS components, not a toolchain. curl.exe and tar.exe ship in System32 on Server 2022
# and are the only transport into a container with no git; naming them keeps the
# premise honest rather than silently relying on them.
foreach ($t in 'cmd', 'powershell', 'curl', 'tar', 'certutil', 'msiexec', 'reg', 'where', 'pwsh', 'node', 'npm', 'msbuild', 'dotnet', 'gcc') {
$c = Get-Command $t -ErrorAction SilentlyContinue
Write-Host (" {0,-10} {1}" -f $t, $(if ($c) { $c.Source } else { 'ABSENT' }))
}
Section 'virginity: no toolchain on disk either'
# A binary can be absent from PATH and still be found by uv's own interpreter
# discovery or by py.exe's registry view -- that is exactly how the hosted Windows
# leg once reported `python ABSENT` and then installed with the runner's 3.13.14.
# Check the disk and the registry, not just PATH.
# A binary can be off PATH and still be found by uv's interpreter discovery or py.exe's
# registry view -- exactly how the hosted Windows leg once reported `python ABSENT` and
# then installed with the runner's 3.13.14. So check disk and registry too.
$badPaths = @(
'C:\Python27', 'C:\Python3*', 'C:\Program Files\Python*', 'C:\Program Files (x86)\Python*',
'C:\Program Files\Git', 'C:\Program Files\CMake', 'C:\Program Files\Microsoft Visual Studio',
'C:\Program Files (x86)\Microsoft Visual Studio', 'C:\hostedtoolcache', 'C:\ProgramData\chocolatey'
)
foreach ($p in $badPaths) {
# Wildcards can match several directories; take the first so the message names a
# real path instead of stringifying an array.
# Wildcards can match several dirs; take the first so the message names a real
# path instead of stringifying an array.
$hit = @(Get-Item -Path $p -ErrorAction SilentlyContinue) | Select-Object -First 1
if ($hit) {
Write-Host " PRESENT $($hit.FullName)"
@ -115,12 +111,12 @@ foreach ($scope in 'Machine', 'User') {
# ── The VC++ runtime question the hosted leg cannot answer ────────────────────
Section 'VC++ runtime (honest measurement)'
# clean-machine-install-ci.yml carries an explicit HONESTY NOTE that the hosted image
# ships the VC++ 2015-2022 runtime in System32 and it cannot be removed without
# breaking the runner, so `import torch` succeeding there does NOT prove a no-winget
# machine has the runtime. This container is the only environment in CI that can
# answer it, so their absence is asserted, not merely recorded: if a future base image
# starts shipping them the lane silently degrades into another masked leg.
# The hosted image ships the VC++ 2015-2022 runtime in System32 and cannot lose it
# without breaking the runner (see the HONESTY NOTE in clean-machine-install-ci.yml),
# so `import torch` succeeding there does NOT prove a no-winget machine has the
# runtime. This container is the only environment in CI that can answer it, so their
# absence is asserted, not merely recorded: if a future base image starts shipping
# them the lane silently degrades into another masked leg.
foreach ($dll in 'vcruntime140.dll', 'vcruntime140_1.dll', 'msvcp140.dll') {
$p = Join-Path $env:WINDIR "System32\$dll"
$present = Test-Path $p
@ -135,9 +131,9 @@ foreach ($k in 'HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64',
# ── Can the installer's transport work at all here? ───────────────────────────
Section 'outbound HTTPS and TLS'
# install.ps1 never sets [Net.ServicePointManager]::SecurityProtocol, so it inherits
# the .NET Framework default. Test the DEFAULT first: if that fails and Tls12 works,
# the installer has a real portability bug on hardened images, not a container quirk.
# install.ps1 never sets [Net.ServicePointManager]::SecurityProtocol, so it inherits the
# .NET Framework default. Test the DEFAULT first: if that fails and Tls12 works, the
# installer has a real portability bug on hardened images, not a container quirk.
Write-Host "default SecurityProtocol: $([Net.ServicePointManager]::SecurityProtocol)"
$probeUrls = @(
'https://www.python.org/ftp/python/',

File diff suppressed because it is too large Load diff

View file

@ -13,15 +13,15 @@
# installs, the binary is present, of the right architecture, and clears the gatekeeper
# checks a user hits (macOS quarantine + codesign, Windows installer exit); the process
# STAYS UP past its preflight, where an unhappy app dies; and it writes tauri.log with a
# preflight disposition, the field that read `ManagedReady` over an unbootable venv in
# the bug report. Linux gets the strongest check: a real webview under Xvfb.
# preflight disposition, the field that read `ManagedReady` over an unbootable venv in the
# bug report. Linux gets the strongest check: a real webview under Xvfb.
name: Desktop app clean machine
on:
# Also on PRs touching this job or the stripping scripts: dispatch resolves the
# workflow from the DEFAULT branch, so a new or edited file on a feature branch can
# never be dispatched and would first run only after merging blind.
# Also on PRs touching this job or the stripping scripts: dispatch resolves the workflow
# from the DEFAULT branch, so a new or edited file on a feature branch can never be
# dispatched and would first run only after merging blind.
pull_request:
paths:
- '.github/workflows/desktop-app-clean-machine-ci.yml'
@ -50,21 +50,21 @@ concurrency:
cancel-in-progress: true
permissions:
# Drafts are listed only to a token with push access, and every desktop-v* release in
# this repo is a draft, so `contents: read` cannot see the bundle under test at all.
# Drafts are listed only to a token with push access, and every desktop-v* release here
# is a draft, so `contents: read` cannot see the bundle under test at all.
contents: write
env:
# release-desktop.yml publishes into github.repository, so a nightly aimed anywhere
# else goes green over a broken production bundle. unsloth-test/unsloth-test holds
# one frozen release, so the schedule was re-testing the same fixture forever.
# release-desktop.yml publishes into github.repository, so a nightly aimed anywhere else
# goes green over a broken production bundle. unsloth-test/unsloth-test holds one frozen
# release, so the schedule was re-testing the same fixture forever.
REL_REPO: ${{ inputs.release_repo || github.repository }}
# Empty unless dispatched: a pinned tag is an immutable fixture, so a nightly against
# it could never catch a newly published broken bundle. Each download step then
# resolves the newest desktop-v* release, drafts included -- every desktop-v* release
# here is cut as a draft, so --exclude-drafts matched nothing and every leg died
# resolving. releases/tags/<tag> 404s for a draft, but gh looks drafts up over GraphQL,
# so `gh release download <tag>` still fetches their assets.
# Empty unless dispatched: a pinned tag is an immutable fixture, so a nightly against it
# could never catch a newly published broken bundle. Each download step then resolves
# the newest desktop-v* release, drafts included -- every desktop-v* release here is cut
# as a draft, so --exclude-drafts matched nothing and every leg died resolving.
# releases/tags/<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'
@ -73,8 +73,8 @@ jobs:
# ── macOS: .dmg, Apple Silicon ────────────────────────────────────────────
macos:
# A fork PR's token is read-only however this workflow declares permissions, so it
# cannot list the draft releases every desktop-v* bundle is published as. Skip
# rather than fail: it is a property of the trigger, not a broken release.
# cannot list the draft releases every desktop-v* bundle is published as. Skip rather
# than fail: a property of the trigger, not a broken release.
if: github.event.pull_request.head.repo.fork != true
name: desktop macOS ${{ matrix.os }}
runs-on: ${{ matrix.os }}
@ -98,8 +98,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p dl logs
# Desktop releases are prereleases (never repo-wide "latest") and drafts, so
# the newest desktop-v* tag has to be resolved explicitly. See REL_TAG above.
# Desktop releases are prereleases (never repo-wide "latest") and drafts, so the
# newest desktop-v* tag has to be resolved explicitly. See REL_TAG above.
if [ -z "$REL_TAG" ]; then
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \
--json tagName,createdAt \
@ -118,10 +118,9 @@ jobs:
ls -la dl
- name: Strip the developer toolchain
# `inputs` exists only for workflow_dispatch, so elsewhere strip_toolchain is
# '' -- and loose equality coerces both '' and false to 0, making `!= false`
# FALSE, so automatic runs would keep the very toolchain this removes. Gate on
# the event instead.
# `inputs` exists only for workflow_dispatch, so elsewhere strip_toolchain is '' --
# and loose equality coerces both '' and false to 0, making `!= false` FALSE, so
# automatic runs would keep the very toolchain this removes. Gate on the event.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
run: |
bash .github/scripts/clean-machine-env.sh mask --remove
@ -131,8 +130,8 @@ jobs:
- name: Mount and install
run: |
DMG="$(ls dl/*.dmg | head -1)"
# A real download is quarantined, and Gatekeeper treats that differently
# from a locally built bundle: a genuine failure mode.
# A real download is quarantined, and Gatekeeper treats that differently from a
# locally built bundle: a genuine failure mode.
xattr -w com.apple.quarantine \
"0081;$(printf %x $(date +%s));Safari;" "$DMG" 2>/dev/null || true
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint /Volumes/UnslothCI
@ -148,8 +147,8 @@ jobs:
BIN="$APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist")"
file "$BIN"
# `lipo -archs` prints and exits 0 for a thin x86_64 binary, and `|| true`
# swallowed even that, so architecture was never asserted. lipo is an xcrun
# shim, gone once the strip moved CommandLineTools aside; file is base system.
# swallowed even that, so architecture was never asserted. lipo is an xcrun shim,
# gone once the strip moved CommandLineTools aside; file is base system.
ARCHS="$(lipo -archs "$BIN" 2>/dev/null || true)"
[ -n "$ARCHS" ] || ARCHS="$(file -b "$BIN")"
echo "architectures: $ARCHS"
@ -157,14 +156,14 @@ jobs:
*arm64*|*aarch64*) ;;
*) echo "::error::the aarch64 .dmg carries no arm64 binary ($ARCHS)"; exit 1 ;;
esac
# Report rather than gate: an unnotarised beta is expected to fail
# assessment, but a user WILL hit this, so it must be visible.
# Report rather than gate: an unnotarised beta is expected to fail assessment, but
# a user WILL hit this, so it must be visible.
codesign -dv --verbose=2 "$APP" 2>&1 | head -20 || true
spctl -a -vvv -t install "$APP" 2>&1 | head -5 || \
echo "::warning::Gatekeeper assessment failed -- users see 'cannot be opened' unless notarised"
# The bundled installer is what actually failed for users, and `::error::` is
# only an annotation that `echo` exits 0 from, so `|| echo` let a bundle with
# no installer pass.
# The bundled installer is what actually failed for users, and `::error::` is only
# an annotation that `echo` exits 0 from, so `|| echo` let a bundle with no
# installer pass.
if [ -f "$APP/Contents/Resources/install.sh" ]; then
echo "bundled install.sh present"
else
@ -179,15 +178,15 @@ jobs:
APP="$(ls -d /Applications/*Unsloth*.app | head -1)"
# A headless runner never clicks Install: preflight sets `not_installed` and
# returns (use-tauri-backend.ts:252-254) while startup-screen.tsx:388-389 waits
# for the button, so launching alone sits there for 90s without ever running
# the bundled installer. Invoke it as src-tauri/src/install.rs does: --tauri,
# stdin closed, no tty. --tauri rejects a custom studio home
# (install.sh:102-114), so drop the override.
# KNOWN OUTCOME PIN, retire when the desktop release catches up to #7547.
# REL_TAG predates #7547, so the bundle's own install.sh still hard-exits on
# the Xcode CLT gate that #7547 replaced with a warning. No change to this PR
# can move that; only a new release can. _check_macos_deps is the function
# #7547 added, so finding it means the release caught up and this pin must go.
# for the button, so launching alone sits there for 90s without ever running the
# bundled installer. Invoke it as src-tauri/src/install.rs does: --tauri, stdin
# closed, no tty. --tauri rejects a custom studio home (install.sh:102-114), so
# drop the override.
# KNOWN OUTCOME PIN, retire when the desktop release catches up to #7547. REL_TAG
# predates #7547, so the bundle's own install.sh still hard-exits on the Xcode
# CLT gate that #7547 replaced with a warning. Only a new release can move that,
# not this PR. _check_macos_deps is the function #7547 added, so finding it means
# the release caught up and this pin must go.
SH="$APP/Contents/Resources/install.sh"
if grep -q '_check_macos_deps' "$SH"; then
echo "::error::the bundled install.sh now carries #7547; delete this pin block and let the venv + torch assertions below run unconditionally"
@ -210,8 +209,8 @@ jobs:
PY="$HOME/.unsloth/studio/unsloth_studio/bin/python"
[ -x "$PY" ] || { echo "::error::bundled installer left no venv at $PY"; exit 1; }
"$PY" -V
# install.rs passes only --tauri, so torch is part of first launch: without
# this the venv check passes a bundle whose only failure is the torch install.
# install.rs passes only --tauri, so torch is part of first launch: without this
# the venv check passes a bundle whose only failure is the torch install.
"$PY" -c "import torch; print('torch', torch.__version__)"
- name: Launch and prove it stays up
@ -251,18 +250,18 @@ jobs:
done
# Everything above is `|| true`, so this step could not fail while the header
# sells the tauri.log disposition as an acceptance criterion. setup_logging
# (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally at process
# start, so no log means the binary never got that far, and the disposition
# line is the field the bug report turned on: a process that hangs before
# preflight must not pass.
# (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally at process start,
# so no log means the binary never got that far, and the disposition line is the
# field the bug report turned on: a process that hangs before preflight must not
# pass.
[ "${found:-0}" = "1" ] || { echo "::error::the app wrote no tauri.log; it never reached setup_logging"; exit 1; }
[ "${disposition:-0}" = "1" ] || { echo "::error::tauri.log records no desktop_preflight disposition; the app never completed preflight"; exit 1; }
- name: Restore the runner
if: always()
# `|| true` swallowed everything, including a restore that genuinely broke. The
# file only exists once the strip step ran, and an earlier step can fail before
# that, so skip explicitly when it is absent and let a real failure surface.
# `|| true` swallowed everything, a genuinely broken restore included. The file
# only exists once the strip step ran, and an earlier step can fail before that,
# so skip explicitly when it is absent and let a real failure surface.
run: |
if [ -f .clean-machine/restore.sh ]; then
bash .clean-machine/restore.sh
@ -281,9 +280,7 @@ jobs:
# ── Linux: .deb and .AppImage, with a real webview under Xvfb ────────────
linux:
# A fork PR's token is read-only however this workflow declares permissions, so it
# cannot list the draft releases every desktop-v* bundle is published as. Skip
# rather than fail: it is a property of the trigger, not a broken release.
# See the macOS job: a fork PR's token cannot list drafts, so skip rather than fail.
if: github.event.pull_request.head.repo.fork != true
name: desktop linux ${{ matrix.kind }}
runs-on: ubuntu-22.04
@ -306,14 +303,12 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p dl logs
# Desktop releases are prereleases (never repo-wide "latest") and drafts, so
# the newest desktop-v* tag has to be resolved explicitly. See REL_TAG above.
# See the macOS job. Loud on purpose: no bundle means nothing to prove.
if [ -z "$REL_TAG" ]; then
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \
--json tagName,createdAt \
--jq '[.[] | select(.tagName | startswith("desktop-v"))]
| sort_by(.createdAt) | reverse | .[0].tagName // empty')"
# Loud on purpose: there is no bundle to test, so passing would prove nothing.
[ -n "$REL_TAG" ] || {
echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)"
exit 1
@ -326,14 +321,14 @@ jobs:
ls -la dl
- name: Strip the developer toolchain
# Same gate as macOS: without it the Linux rows ignored strip_toolchain and ran
# the bundled installer with the runner's git, gcc, cmake and make in /usr/bin.
# Same gate as macOS: without it the Linux rows ignored strip_toolchain and ran the
# bundled installer with the runner's git, gcc, cmake and make in /usr/bin.
#
# BEFORE the bundle install, as macOS and Windows already do: dpkg runs the
# package's own maintainer scripts, so installing first let them see the hosted
# image's toolchain. Nothing in that install needs a masked tool --
# clean-machine-env.sh moves aside only $TOOLS, leaving the package manager
# itself -- and the current bundle ships a postrm and no install-time script.
# clean-machine-env.sh moves aside only $TOOLS, leaving the package manager itself
# -- and the current bundle ships a postrm and no install-time script.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
run: |
bash .github/scripts/clean-machine-env.sh mask --remove
@ -342,9 +337,9 @@ jobs:
- name: Install with NO dev tooling, only runtime libs
run: |
# Deliberately not build-essential/cmake/git: a user installing a .deb has
# none of that. Xvfb and WebKit are runtime requirements, and apt pulls the
# .deb's declared deps, so a wrong dependency list fails here.
# Deliberately not build-essential/cmake/git: a user installing a .deb has none
# of that. Xvfb and WebKit are runtime requirements, and apt pulls the .deb's
# declared deps, so a wrong dependency list fails here.
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends xvfb
if [ "${{ matrix.kind }}" = "deb" ]; then
@ -362,12 +357,12 @@ jobs:
echo "BIN=$BIN" >> "$GITHUB_ENV"
echo "binary: $BIN"
# The strip runs before this, but `apt-get install ./dl/*.deb` then pulls the
# bundle's DECLARED dependencies, so a release that adds git, cmake or a compiler
# to that list puts one back in /usr/bin and both required Linux rows still pass.
# `absent` ran only beforehand, so re-run it here, before the bundled installer.
# The current dependency closure is 65 packages of runtime libs and no toolchain,
# so this is green today and only a new dependency can turn it red.
# The strip runs before this, but `apt-get install ./dl/*.deb` then pulls the bundle's
# DECLARED dependencies, so a release that adds git, cmake or a compiler to that list
# puts one back in /usr/bin and both required Linux rows still pass. `absent` ran only
# beforehand, so re-run it here, before the bundled installer. The current dependency
# closure is 65 packages of runtime libs and no toolchain, so this is green today and
# only a new dependency can turn it red.
- name: Re-assert the toolchain is still absent after the package install
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
run: |
@ -379,39 +374,39 @@ jobs:
set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a
set -o pipefail
# The launch step below only proves the process stayed alive: on a fresh home
# preflight reports not_installed and the app waits on the install screen for
# a click (use-tauri-backend.ts:252-254, startup-screen.tsx:388-389), so a
# bundle whose embedded install.sh was missing or broken passed both Linux
# rows. tauri.conf.json:56-59 ships it as a bundle resource, so find it there
# and run it as install.rs does.
# preflight reports not_installed and the app waits on the install screen for a
# click (use-tauri-backend.ts:252-254, startup-screen.tsx:388-389), so a bundle
# whose embedded install.sh was missing or broken passed both Linux rows.
# tauri.conf.json:56-59 ships it as a bundle resource, so find it there and run
# it as install.rs does.
if [ "${{ matrix.kind }}" = "deb" ]; then
SH="$(dpkg -L "$(dpkg-deb -f dl/*.deb Package)" | grep -E '/install\.sh$' | head -1)"
else
# ls returns a bare filename here, and a command word with no slash is
# resolved through PATH, not the cwd, so this needs the ./ prefix.
# ls returns a bare filename here, and a command word with no slash resolves
# through PATH, not the cwd, so this needs the ./ prefix.
(cd dl && "./$(ls *.AppImage | head -1)" --appimage-extract >/dev/null)
SH="$(find dl/squashfs-root -name install.sh -type f | head -1)"
fi
[ -n "$SH" ] && [ -f "$SH" ] || { echo "::error::the bundle ships no install.sh resource"; exit 1; }
echo "bundled installer: $SH"
# KNOWN OUTCOME PIN, retire when the desktop release catches up to #7547.
# The bundle carries its own install.sh, and REL_TAG predates #7547, so on a
# stripped runner it still exits 2 at the NEED_SUDO handshake for the optional
# set instead of falling through to prebuilt llama.cpp. No change to this PR
# can move that; only a new release can. _SMART_APT_OPTIONAL is the guard #7547
# added, so finding it means the release caught up and this pin must go.
# KNOWN OUTCOME PIN, retire when the desktop release catches up to #7547. The
# bundle carries its own install.sh and REL_TAG predates #7547, so on a stripped
# runner it still exits 2 at the NEED_SUDO handshake for the optional set instead
# of falling through to prebuilt llama.cpp. Only a new release can move that, not
# this PR. _SMART_APT_OPTIONAL is the guard #7547 added, so finding it means the
# release caught up and this pin must go.
if grep -q '_SMART_APT_OPTIONAL' "$SH"; then
echo "::error::the bundled install.sh now carries #7547; delete this pin block and let the venv + torch assertions below run unconditionally"
exit 1
fi
# --tauri rejects a custom studio home (install.sh:102-114), so drop the
# workspace-scoped override, and close stdin as install.rs does.
# workspace-scoped override; close stdin as install.rs does.
rc=0
env -u UNSLOTH_STUDIO_HOME \
bash "$SH" --tauri < /dev/null 2>&1 | tee logs/bundled-install.log || rc=$?
echo "bundled installer exit code: $rc"
# Exit code AND the exact optional set, so a different NEED_SUDO list or any
# other non-zero exit is still a failure.
# Exit code AND the exact optional set, so a different NEED_SUDO list or any other
# non-zero exit is still a failure.
if [ "$rc" -eq 2 ] && grep -qE '^\[TAURI:NEED_SUDO\] cmake git build-essential libcurl4-openssl-dev[[:space:]]*$' logs/bundled-install.log; then
echo "::notice::known pre-#7547 outcome: the shipped bundle's install.sh asked to elevate for the optional set and exited 2. Not a regression here; the next desktop release retires this pin."
exit 0
@ -429,9 +424,9 @@ jobs:
- name: Launch under Xvfb and prove it stays up
run: |
set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a
# Linux is the one platform where a hosted runner can give the app a real
# display, so this is the strongest "does the UI come up" check available
# without self-hosted hardware.
# Linux is the one platform where a hosted runner can give the app a real display,
# so this is the strongest "does the UI come up" check available without
# self-hosted hardware.
xvfb-run -a --server-args="-screen 0 1440x900x24" \
"$BIN" > logs/app-stdout.log 2>&1 &
APP_PID=$!
@ -459,20 +454,15 @@ jobs:
found=1
if grep -qE "desktop_preflight completed disposition=" "$f"; then disposition=1; fi
done
# Same acceptance criterion the macOS rows enforce. Everything above is
# `|| true` and the loop skips a missing log, so without these two lines the
# step could not fail. setup_logging (src-tauri/src/main.rs:50-67) opens
# tauri.log at process start, so no log means the binary never got that far,
# and the launch step only proves liveness: an app hanging before preflight
# completes would otherwise pass both Linux rows.
# Same acceptance criterion the macOS rows enforce, and for the same reason:
# everything above is `|| true` and the loop skips a missing log, so without
# these two lines the step could not fail.
[ "${found:-0}" = "1" ] || { echo "::error::the app wrote no tauri.log; it never reached setup_logging"; exit 1; }
[ "${disposition:-0}" = "1" ] || { echo "::error::tauri.log records no desktop_preflight disposition; the app never completed preflight"; exit 1; }
- name: Restore the runner
if: always()
# `|| true` swallowed everything, including a restore that genuinely broke. The
# file only exists once the strip step ran, and an earlier step can fail before
# that, so skip explicitly when it is absent and let a real failure surface.
# See the macOS job: `|| true` would swallow a genuinely broken restore.
run: |
if [ -f .clean-machine/restore.sh ]; then
bash .clean-machine/restore.sh
@ -491,9 +481,7 @@ jobs:
# ── Windows: NSIS setup.exe, silent install ──────────────────────────────
windows:
# A fork PR's token is read-only however this workflow declares permissions, so it
# cannot list the draft releases every desktop-v* bundle is published as. Skip
# rather than fail: it is a property of the trigger, not a broken release.
# See the macOS job: a fork PR's token cannot list drafts, so skip rather than fail.
if: github.event.pull_request.head.repo.fork != true
name: desktop windows
runs-on: windows-latest
@ -512,14 +500,12 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p dl logs
# Desktop releases are prereleases (never repo-wide "latest") and drafts, so
# the newest desktop-v* tag has to be resolved explicitly. See REL_TAG above.
# See the macOS job. Loud on purpose: no bundle means nothing to prove.
if [ -z "$REL_TAG" ]; then
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \
--json tagName,createdAt \
--jq '[.[] | select(.tagName | startswith("desktop-v"))]
| sort_by(.createdAt) | reverse | .[0].tagName // empty')"
# Loud on purpose: there is no bundle to test, so passing would prove nothing.
[ -n "$REL_TAG" ] || {
echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)"
exit 1
@ -531,24 +517,21 @@ jobs:
ls -la dl
- name: Strip the developer toolchain
# `inputs` exists only for workflow_dispatch, so on pull_request and schedule
# `inputs.strip_toolchain` is '' -- and loose equality coerces both '' and
# false to 0, making `!= false` FALSE, so automatic runs would keep the hosted
# toolchain this workflow exists to remove. Gate on the event instead.
# Same gate as macOS, for the same `inputs`-coercion reason.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
shell: pwsh
run: |
$drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\', 'CMake',
'Microsoft Visual Studio', 'BuildTools', 'LLVM', 'MSYS', 'mingw')
# winget is an app-execution alias under ...\Local\Microsoft\WindowsApps, so
# the WindowsApps fragment -- there to take the Store's python.exe alias away
# -- drops the OS package manager with it. winget is not developer tooling;
# every consumer Windows machine this bundle ships to has it, and the bundled
# winget is an app-execution alias under ...\Local\Microsoft\WindowsApps, so the
# WindowsApps fragment -- there to take the Store's python.exe alias away --
# drops the OS package manager with it. winget is not developer tooling: every
# consumer Windows machine this bundle ships to has it, and the bundled
# install.ps1 reaches for it for the git that studio/setup.ps1:1657-1669 still
# gates on unconditionally. Without it this lane only re-runs the no-winget
# fallback that clean-machine-install-ci.yml already covers and pins on its
# winget=masked row, and it does so as a hard failure. Resolve winget before
# the scrub and hand it back through a shim, exactly as that workflow does.
# gates on unconditionally. Without it this lane only re-runs, as a hard failure,
# the no-winget fallback clean-machine-install-ci.yml already covers and pins on
# its winget=masked row. Resolve winget before the scrub and hand it back through
# a shim, exactly as that workflow does.
$wingetCmd = Get-Command winget -ErrorAction SilentlyContinue
if (-not $wingetCmd) {
Write-Host '::error::winget was not on PATH before the strip; this image ships it and the bundled installer needs it'
@ -564,9 +547,8 @@ jobs:
}
"PATH=$shim;$((& $scrub ($env:PATH -split ';')) -join ';')" |
Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Take the toolcache Python off disk, not just off PATH: py.exe lives in
# C:\Windows (which must stay) and uv does its own interpreter discovery,
# so both reach the toolcache whatever PATH says.
# Off disk, not just off PATH: py.exe lives in C:\Windows (which must stay) and
# uv does its own discovery, so both reach the toolcache whatever PATH says.
foreach ($tc in @("$env:AGENT_TOOLSDIRECTORY\Python", 'C:\hostedtoolcache\windows\Python')) {
if ($tc -and (Test-Path $tc)) {
try { Rename-Item -LiteralPath $tc -NewName 'Python.masked' -ErrorAction Stop
@ -574,13 +556,12 @@ jobs:
catch { Write-Host "::error::could not mask $tc ($($_.Exception.Message)); the job would not be clean"; exit 1 }
}
}
# The bundled install.ps1 this job runs calls Refresh-SessionPath (318-337),
# which merges the Machine and User registry PATHs back into $env:Path, so a
# process-only scrub lasts until the first refresh and Git/CMake/VS/LLVM come
# back from the registry. The runner is ephemeral, so rewrite the registry
# copies too. (A merge keeps what the process already had, which is why the
# winget shim above survives.) Expand
# first: SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ
# The bundled install.ps1 this job runs calls Refresh-SessionPath (318-337), which
# merges the Machine and User registry PATHs back into $env:Path, so a
# process-only scrub lasts until the first refresh and Git/CMake/VS/LLVM come back
# from the registry. The runner is ephemeral, so rewrite the registry copies too.
# (A merge keeps what the process already had, which is why the winget shim above
# survives.) Expand first: SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ
# (dotnet/runtime#1442).
foreach ($scope in 'Machine','User') {
$raw = [System.Environment]::GetEnvironmentVariable('Path', $scope)
@ -599,11 +580,11 @@ jobs:
exit 0
- name: Verify the strip took effect
# PATH written to $GITHUB_ENV only applies to LATER steps, so the scrub can
# only be checked from here. The drop list above is heuristic path-fragment
# matching: if a runner image moves any of these tools outside those fragments,
# the bundled install.ps1 reuses the survivor and this job still calls itself
# clean. Same assertion the installer workflow runs, same reason.
# PATH written to $GITHUB_ENV only applies to LATER steps, so the scrub can only be
# checked from here. The drop list above is heuristic path-fragment matching: if a
# runner image moves any of these tools outside those fragments, the bundled
# install.ps1 reuses the survivor and this job still calls itself clean. Same
# assertion the installer workflow runs, same reason.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
shell: pwsh
run: |
@ -615,8 +596,8 @@ jobs:
}
# `py` itself lives in C:\Windows and stays. Only an interpreter it can still
# START is a leak, because Find-CompatiblePython (install.ps1:1130-1153) probes
# `py` first. `py -0p` is just the launcher's REGISTRY view, which still names
# the paths the rename removed, so a start attempt is the only real evidence.
# `py` first. `py -0p` is only the launcher's REGISTRY view, which still names the
# paths the rename removed, so a start attempt is the only real evidence.
if (Get-Command py -ErrorAction SilentlyContinue) {
foreach ($v in '-3.11', '-3.12', '-3.13') {
$out = & py $v -c "import sys; print(sys.executable)" 2>&1
@ -624,16 +605,15 @@ jobs:
Write-Host ("py {0} -> exit {1}: {2}" -f $v, $rc, (($out | Out-String).Trim() -replace '\r?\n', ' / '))
if ($rc -eq 0) { $leaked += "py $v -> $out" }
}
# A failing probe is the outcome we want, but it leaves $LASTEXITCODE
# non-zero and the runner appends `exit $LASTEXITCODE` to every pwsh step
# (actions/runner#351), so the step would exit 1 with nothing printed on a
# machine that is in fact clean.
# A failing probe is the outcome we want, but it leaves $LASTEXITCODE non-zero
# and the runner appends `exit $LASTEXITCODE` to every pwsh step
# (actions/runner#351), so the step would exit 1 on a machine that is clean.
$global:LASTEXITCODE = 0
}
# The shim is the only reason winget resolves after the WindowsApps drop. It
# survives the installer's own refreshes because Refresh-SessionPath
# (install.ps1:318-337) and setup.ps1's Refresh-Environment MERGE the current
# $env:Path back in rather than replace it -- but assert it, or this lane
# $env:Path back in rather than replace it -- but assert that, or this lane
# silently degrades into the no-winget leg the installer workflow already pins.
$winget = Get-Command winget -ErrorAction SilentlyContinue
Write-Host ("winget {0}" -f $(if ($winget) { $winget.Source } else { 'ABSENT' }))
@ -651,8 +631,8 @@ jobs:
shell: pwsh
run: |
$exe = (Get-ChildItem dl/*setup.exe | Select-Object -First 1).FullName
# /S is the NSIS silent switch: a user double-clicks, but an installer that
# cannot run unattended cannot be scripted or MDM-deployed either.
# /S is the NSIS silent switch: a user double-clicks, but an installer that cannot
# run unattended cannot be scripted or MDM-deployed either.
$p = Start-Process -FilePath $exe -ArgumentList '/S' -Wait -PassThru
Write-Host "installer exit: $($p.ExitCode)"
if ($p.ExitCode -ne 0) { Write-Host "::error::silent install failed"; exit 1 }
@ -666,8 +646,8 @@ jobs:
- name: Run the bundled installer, the path first launch takes
shell: pwsh
run: |
# The launch step below only proves the process stayed alive: on a fresh
# profile the app waits for a click on Install (use-tauri-backend.ts:252-254,
# The launch step below only proves the process stayed alive: on a fresh profile
# the app waits for a click on Install (use-tauri-backend.ts:252-254,
# startup-screen.tsx:388-389), so this job passed on a bundle whose embedded
# install.ps1 was missing or broken. tauri.conf.json:56-59 ships it as a bundle
# resource, so find it where NSIS put it and invoke it as install.rs:326-341.
@ -697,8 +677,8 @@ jobs:
exit 1
}
& $py -V
# install.rs passes only --tauri, so torch is part of first launch, and a venv
# that cannot import it is the unbootable environment from the report.
# install.rs passes only --tauri, so torch is part of first launch, and a venv that
# cannot import it is the unbootable environment from the report.
& $py -c "import torch; print('torch', torch.__version__)"
if ($LASTEXITCODE -ne 0) {
Write-Host '::error::the bundled install produced a venv with no working torch'
@ -738,11 +718,8 @@ jobs:
-SimpleMatch -Quiet) { $disposition = $true }
}
}
# Same acceptance criterion macOS and Linux enforce. Test-Path, Get-Content and
# Same acceptance criterion macOS and Linux enforce: Test-Path, Get-Content and
# Select-String cannot fail, so without these two lines the step was decoration.
# setup_logging (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally at
# process start, so no log means the binary never got that far, and an app that
# hangs before preflight would otherwise pass.
if (-not $found) {
Write-Host '::error::the app wrote no tauri.log; it never reached setup_logging'
exit 1

View file

@ -2766,18 +2766,17 @@ exit 0
# text, ignored unless UNSLOTH_CI_SOURCE_OVERLAY names a directory holding a
# pyproject.toml.
#
# The clean-machine legs run THIS script from a branch, but it installs
# unsloth from PyPI, the consumer path, so everything Python-side comes out
# of the released wheel (studio/setup.ps1, install_python_stack.py and every
# requirements/constraints file they reach via Path(__file__)) and the
# workflow meant to validate a branch could not. `& $UnslothExe studio setup`
# below goes through the CLI, and an editable overlay makes _PACKAGE_ROOT in
# unsloth_cli/commands/studio.py resolve to the working tree by PEP 660
# __file__, so setup.ps1 comes from the branch unchanged. NOT --local: that
# also installs `unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo`,
# which genuinely needs git, and git absence is what the masked leg proves.
# Editable + --no-deps resolves nothing and clones nothing, so it survives
# git, cmake and MSVC all missing.
# The clean-machine legs run THIS script from a branch but install unsloth
# from PyPI, the consumer path, so everything Python-side (studio/setup.ps1,
# install_python_stack.py and every requirements/constraints file they reach
# via Path(__file__)) would be the released wheel's and a branch could not be
# validated. `& $UnslothExe studio setup` below goes through the CLI, and an
# editable overlay makes _PACKAGE_ROOT in unsloth_cli/commands/studio.py
# resolve to the working tree by PEP 660 __file__, so setup.ps1 comes from
# this ref. NOT --local: that also installs `unsloth-zoo @
# git+https://github.com/unslothai/unsloth-zoo`, which genuinely needs git,
# and git absence is what the masked leg proves; editable + --no-deps
# resolves and clones nothing, so it survives git, cmake and MSVC all missing.
if ($env:UNSLOTH_CI_SOURCE_OVERLAY) {
$CiOverlayRoot = $env:UNSLOTH_CI_SOURCE_OVERLAY
if (-not (Test-Path -LiteralPath (Join-Path $CiOverlayRoot "pyproject.toml"))) {
@ -2785,8 +2784,8 @@ exit 0
return (Exit-InstallFailure "UNSLOTH_CI_SOURCE_OVERLAY has no pyproject.toml: $CiOverlayRoot")
}
substep "CI: overlaying source checkout (editable, no deps): $CiOverlayRoot"
# Retry: the editable build downloads its pinned build backend from PyPI,
# so it carries the same transient-network risk as every other step.
# Retry: the editable build fetches its build backend from PyPI, same
# transient-network risk as every other step.
$CiOverlayExit = Invoke-InstallCommandRetry -Label "overlay CI source checkout" -Command { uv pip install --python $VenvPython --no-deps -e $CiOverlayRoot }
if ($CiOverlayExit -ne 0) {
return (Exit-InstallFailure "Failed to overlay the CI source checkout (exit code $CiOverlayExit)" $CiOverlayExit)

View file

@ -4189,16 +4189,15 @@ fi
# Not a consumer knob: no flag, absent from --help, ignored unless
# UNSLOTH_CI_SOURCE_OVERLAY names a directory holding a pyproject.toml.
#
# The clean-machine legs run THIS script from a branch, but it installs unsloth
# from PyPI, the consumer path. Everything Python-side then comes out of the
# released wheel (studio/setup.sh, setup.ps1, install_python_stack.py and every
# requirements/constraints file they reach via Path(__file__)), so the workflow
# meant to validate a branch could not. An editable overlay re-points
# `import studio` at the working tree, and the importlib.resources lookup below
# then finds the branch's setup.sh unchanged. NOT --local: that also installs
# `unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo`, which genuinely
# needs git, and git absence is what these legs prove. Editable + --no-deps
# resolves nothing and clones nothing, so it survives git, cmake and the C/C++
# The clean-machine legs run THIS script from a branch but install unsloth from
# PyPI, the consumer path, so everything Python-side (studio/setup.sh, setup.ps1,
# install_python_stack.py and every requirements/constraints file they reach via
# Path(__file__)) would be the released wheel's and a branch could not be
# validated. An editable overlay re-points `import studio` at the working tree, so
# the importlib.resources lookup below finds this ref's setup.sh. NOT --local:
# that also installs `unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo`,
# which genuinely needs git, and git absence is what these legs prove; editable +
# --no-deps resolves and clones nothing, so it survives git, cmake and the C/C++
# compilers all being gone.
if [ -n "${UNSLOTH_CI_SOURCE_OVERLAY:-}" ]; then
if [ ! -f "$UNSLOTH_CI_SOURCE_OVERLAY/pyproject.toml" ]; then
@ -4206,8 +4205,8 @@ if [ -n "${UNSLOTH_CI_SOURCE_OVERLAY:-}" ]; then
exit 1
fi
substep "CI: overlaying source checkout (editable, no deps): $UNSLOTH_CI_SOURCE_OVERLAY"
# Retry: the editable build downloads its pinned build backend from PyPI, so
# it carries the same transient-network risk as every other install step.
# Retry: the editable build fetches its build backend from PyPI, same
# transient-network risk as every other install step.
run_install_cmd_retry "overlay CI source checkout" uv pip install --python "$_VENV_PY" \
--no-deps -e "$UNSLOTH_CI_SOURCE_OVERLAY"
fi

View file

@ -2790,8 +2790,7 @@ def pip_install_try(
env = _install_env_for_cmd(cmd),
)
if result.returncode == 0:
# Same reasoning as pip_install below: `nobuild` can only catch a source
# build that reaches the log.
# As pip_install below: `nobuild` only catches a build that reaches the log.
if VERBOSE and result.stdout:
print(_redact_install_output(result.stdout))
return True
@ -2849,14 +2848,13 @@ def pip_install(
**_windows_hidden_subprocess_kwargs(),
)
if result.returncode == 0:
# Echo successful output under UNSLOTH_VERBOSE, as install.sh's
# Echo success under UNSLOTH_VERBOSE, as install.sh's
# run_install_cmd does. Without it the dependency phase never
# reached the install log, and clean-machine-assert.sh's `nobuild`
# greps that log for uv's "Building <pkg>==<ver>" -- so a source
# build in this step, the one installing studio.txt where an
# sdist-only dependency actually shows up, reported "built: none"
# and the leg stayed green. Redacted: uv echoes index URLs with
# credentials.
# 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