Compare commits
41 commits
main
...
ci/clean-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6219fa4d3 | ||
|
|
f09a1e71a9 | ||
|
|
d6d2408ce6 | ||
|
|
94056e0c09 | ||
|
|
467d02b1dd | ||
|
|
906ef42ead | ||
|
|
b992e72750 | ||
|
|
31eb3aed45 | ||
|
|
c6168a31fa | ||
|
|
404e38baf9 | ||
|
|
22495b3485 | ||
|
|
04ca461b55 | ||
|
|
1a6d0da800 | ||
|
|
a1cd794239 | ||
|
|
0f760008ce | ||
|
|
c6a1174e67 | ||
|
|
d1223f7cc0 | ||
|
|
58e5fa5e6e | ||
|
|
9b0f6d76d4 | ||
|
|
627ca71179 | ||
|
|
b5042eed84 | ||
|
|
83c3dba5ab | ||
|
|
7d4311fe58 | ||
|
|
2027e157c3 | ||
|
|
d5f747ef5e | ||
|
|
b8a052080e | ||
|
|
b905784f77 | ||
|
|
06d2725e09 | ||
|
|
afbdaa09b7 | ||
|
|
3be21e87cf | ||
|
|
9a8a749d07 | ||
|
|
50afa4d21c | ||
|
|
b573f067d1 | ||
|
|
85305a2163 | ||
|
|
6937234f2d | ||
|
|
231fcc3cf0 | ||
|
|
90ec9462a0 | ||
|
|
eea0433f0e | ||
|
|
0699e5c72b | ||
|
|
d2ade8ad1e | ||
|
|
c5b4f5ee86 |
11 changed files with 3459 additions and 0 deletions
65
.github/scripts/assert-nobuild.ps1
vendored
Normal file
65
.github/scripts/assert-nobuild.ps1
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# The `nobuild` contract from clean-machine-assert.sh, for Windows. A port and not
|
||||
# `shell: bash`: the scrub drops every `*\Git\*` PATH entry and the bash version needs
|
||||
# sed/grep/tr/sort out of Git's usr/bin, and it also runs inside the servercore
|
||||
# container, which has no bash. Both Windows lanes call this one file so the sdist
|
||||
# allowlist cannot drift.
|
||||
#
|
||||
# Usage: assert-nobuild.ps1 -LogPath logs/install.log (exit 1 = a source build)
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory = $true)][string] $LogPath)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $LogPath)) {
|
||||
Write-Host "::error::nobuild requested but $LogPath is missing"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# "Built an sdist" is NOT "needed a compiler": every name here was verified against its
|
||||
# own sdist -- setuptools.build_meta backend, no ext_modules, no .c/.cpp/.pyx/.rs file
|
||||
# -- so its PEP 517 build is a pure-Python copy step. Identical to
|
||||
# clean-machine-assert.sh, which carries the per-name rationale.
|
||||
$allow = @('openai-whisper', 'argbind', 'randomname', 'antlr4-python3-runtime', 'triton-kernels')
|
||||
if ($env:UNSLOTH_ALLOW_SDIST) {
|
||||
$allow += ($env:UNSLOTH_ALLOW_SDIST -split '\s+' | Where-Object { $_ })
|
||||
}
|
||||
# Lowercased and underscore-folded on both sides: a distribution name and the name uv
|
||||
# prints can disagree on the separator (triton_kernels vs triton-kernels).
|
||||
$allow = @($allow | ForEach-Object { $_.ToLowerInvariant() -replace '_', '-' })
|
||||
|
||||
# [char]27, not "`e": that escape is PowerShell 6+, and under Windows PowerShell 5.1
|
||||
# it degrades to a literal "e" and the strip eats real text instead of ANSI codes.
|
||||
$esc = [char]27
|
||||
$text = (Get-Content -LiteralPath $LogPath -Raw) -replace "$esc\[[0-9;]*[A-Za-z]", ''
|
||||
$built = @()
|
||||
foreach ($line in ($text -split "`r?`n")) {
|
||||
# A local-path build is one the caller pointed at (the CI source overlay), never
|
||||
# one resolution chose; index dependencies always print `==<version>`.
|
||||
if ($line -imatch 'building [a-z0-9._-]+ @ file://') { continue }
|
||||
# pip prints `Building wheel for <pkg>`, uv prints `Building <pkg>==<ver>`
|
||||
# (astral-sh/uv#11165); the `==` or ` @ ` requirement keeps this off the
|
||||
# installer's own lowercase "building frontend..." text.
|
||||
foreach ($m in [regex]::Matches($line, '(?i)building wheel for ([a-z0-9._-]+)|building ([a-z0-9._-]+)(==| @ )')) {
|
||||
$name = if ($m.Groups[1].Success) { $m.Groups[1].Value } else { $m.Groups[2].Value }
|
||||
$built += ($name.ToLowerInvariant() -replace '_', '-')
|
||||
}
|
||||
}
|
||||
$built = @($built | Sort-Object -Unique)
|
||||
$bad = @($built | Where-Object { $allow -notcontains $_ })
|
||||
|
||||
$rc = 0
|
||||
if ($bad.Count -gt 0) {
|
||||
Write-Host "::error::built from source: $($bad -join ' ') -- these must resolve to wheels on a clean machine"
|
||||
$rc = 1
|
||||
} else {
|
||||
Write-Host "[assert] OK no non-allowlisted source build (built: $(if ($built) { $built -join ' ' } else { 'none' }))"
|
||||
}
|
||||
# Independent of package names: a compiler error means a toolchain was needed.
|
||||
$compilerErr = Select-String -Path $LogPath -Pattern "error: command '(cc|gcc|clang|cl)' failed", 'clang: error', 'cargo: not found', 'Microsoft Visual C\+\+ 14.0 or greater is required'
|
||||
if ($compilerErr) {
|
||||
Write-Host '::error::compiler invocation appears in the install log'
|
||||
$compilerErr | Select-Object -First 10 | ForEach-Object { Write-Host " $($_.Line)" }
|
||||
$rc = 1
|
||||
}
|
||||
exit $rc
|
||||
261
.github/scripts/clean-machine-assert.sh
vendored
Executable file
261
.github/scripts/clean-machine-assert.sh
vendored
Executable file
|
|
@ -0,0 +1,261 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Assert the clean-machine contract after an install attempt.
|
||||
#
|
||||
# absent The toolchain really was absent for the whole run. Catches a leg that
|
||||
# "passed" because masking silently failed, or because the installer
|
||||
# quietly installed Xcode CLT behind our back.
|
||||
# notools The trace recorded no compiler/git/brew invocation (trace mode).
|
||||
# nobuild Wheels-only: no "Building wheel" from pip, no "Building <pkg>==<ver>"
|
||||
# from uv. Needs UNSLOTH_VERBOSE=1, or run_install_cmd
|
||||
# (install.sh:193-243) discards uv's output on success.
|
||||
# macho Every Mach-O under $MACHO_ROOT is the host architecture, and every
|
||||
# Mach-O MAIN EXECUTABLE is signed. Closes the Rosetta 2 gap, the one
|
||||
# divergence masking cannot reproduce.
|
||||
#
|
||||
# Usage: bash .github/scripts/clean-machine-assert.sh absent notools nobuild macho
|
||||
set -uo pipefail
|
||||
|
||||
LOG="${INSTALL_LOG:-logs/install.log}"
|
||||
TRACE="${UNSLOTH_TOOL_TRACE:-}"
|
||||
rc=0
|
||||
|
||||
fail() { echo "::error::$*"; rc=1; }
|
||||
ok() { echo "[assert] OK $*"; }
|
||||
|
||||
for check in "$@"; do
|
||||
case "$check" in
|
||||
|
||||
absent)
|
||||
# NOT `command -v`: on a virgin Mac /usr/bin/{git,cc} EXIST as CLT stubs, so it
|
||||
# succeeds and only RUNNING them fails. The invariant is: must not WORK.
|
||||
if xcode-select -p >/dev/null 2>&1; then
|
||||
fail "xcode-select -p still resolves to $(xcode-select -p 2>/dev/null); not a clean Mac"
|
||||
else
|
||||
ok "xcode-select -p fails (the gate a virgin Mac hits)"
|
||||
fi
|
||||
for tool in git cc clang cmake; do
|
||||
command -v "$tool" >/dev/null 2>&1 || { ok "$tool not on PATH"; continue; }
|
||||
if "$tool" --version >/dev/null 2>&1; then
|
||||
# On Intel runners /usr/bin/git is not CLT-provided, so no masking can take
|
||||
# it away. cc and clang do become stubs and the macOS consumer path needs
|
||||
# no git, so report rather than fail.
|
||||
case " ${UNSLOTH_CLEAN_ALLOW_WORKING:-} " in
|
||||
*" $tool "*)
|
||||
echo "[assert] NOTE $tool still works ($(command -v "$tool")); allowed on this runner"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fail "toolchain still usable: '$tool --version' succeeded ($(command -v "$tool")); masking failed"
|
||||
else
|
||||
ok "$tool present but non-functional (CLT stub), as on a clean Mac"
|
||||
fi
|
||||
done
|
||||
# brew is a plain binary with no stub, so absence from PATH is the right test.
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
fail "Homebrew still on PATH at $(command -v brew); masking failed"
|
||||
else
|
||||
ok "brew absent"
|
||||
fi
|
||||
;;
|
||||
|
||||
notools)
|
||||
if [ -z "$TRACE" ] || [ ! -f "$TRACE" ]; then
|
||||
fail "notools requested but no trace file (\$UNSLOTH_TOOL_TRACE=$TRACE)"
|
||||
else
|
||||
# git is legitimate under --local (unsloth-zoo comes from a git URL), so that
|
||||
# leg allow-lists it via UNSLOTH_ALLOW_TOOLS.
|
||||
allow="${UNSLOTH_ALLOW_TOOLS:-}"
|
||||
hits=""
|
||||
while IFS=$'\t' read -r tool rest; do
|
||||
[ -n "$tool" ] || continue
|
||||
case " $allow " in *" $tool "*) continue ;; esac
|
||||
# `xcode-select -p` only ASKS whether a toolchain is selected, and the fix
|
||||
# is that the installer carries on without one, so it is not USE.
|
||||
# `--install`, which pops the CLT installer, stays a hit.
|
||||
if [ "$tool" = "xcode-select" ]; then
|
||||
case "$rest" in
|
||||
-p|--print-path|-v|--version|"") continue ;;
|
||||
esac
|
||||
fi
|
||||
hits="$hits $tool"
|
||||
done < "$TRACE"
|
||||
if [ -n "$hits" ]; then
|
||||
fail "installer invoked toolchain:$(echo "$hits" | tr ' ' '\n' | sort -u | tr '\n' ' ')"
|
||||
echo "---- tool trace ----"; sort -u "$TRACE" | head -50
|
||||
else
|
||||
ok "no compiler/git/brew invocation recorded"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
nobuild)
|
||||
# "Built an sdist" is NOT "needed a compiler". Every name below was verified
|
||||
# against its own sdist: setuptools.build_meta backend, no ext_modules, no
|
||||
# .c/.cpp/.pyx/.rs file, so its PEP 517 build is a pure-Python copy step.
|
||||
# openai-whisper, argbind, randomname -- no version ever ships a wheel
|
||||
# antlr4-python3-runtime==4.9.3 -- pinned below the 4.13.2 wheel
|
||||
# triton-kernels -- requirements/triton-kernels.txt pins a git URL under the
|
||||
# triton repo's python/triton_kernels subdir: 75 Python files, a four-line
|
||||
# pyproject.toml, no setup.py, kernels compiled at runtime. A direct URL the
|
||||
# installer names itself, not something resolution chose, and only the Linux
|
||||
# legs reach it (install_python_stack.py skips it on Windows and macOS).
|
||||
# UNSLOTH_ALLOW_SDIST extends the allowlist.
|
||||
#
|
||||
# Lowercased and underscore-folded on both sides: a distribution name and the
|
||||
# name uv prints can disagree on the separator (triton_kernels vs triton-kernels).
|
||||
_allow="$(printf '%s' "openai-whisper argbind randomname antlr4-python3-runtime triton-kernels ${UNSLOTH_ALLOW_SDIST:-}" | tr 'A-Z_' 'a-z-')"
|
||||
if [ ! -f "$LOG" ]; then
|
||||
fail "nobuild requested but $LOG is missing"
|
||||
else
|
||||
# uv prints `Building <name>==<version>`, pip prints `Building wheel for
|
||||
# <name>` (astral-sh/uv#11165), so match both; the `==` or ` @ ` requirement
|
||||
# keeps this off the installer's own lowercase "building frontend..." text, and
|
||||
# ANSI is stripped first so a coloured run (FORCE_COLOR) parses.
|
||||
# `Building <name> @ file://...` is dropped: a local-path build is one the
|
||||
# caller pointed at (--local, or the editable overlay), never one resolution
|
||||
# chose. Index dependencies always print `<name>==<version>`, so a genuine
|
||||
# PyPI sdist is still caught, including one named unsloth.
|
||||
_esc=$(printf '\033')
|
||||
_built="$(sed -E "s/${_esc}\[[0-9;]*[A-Za-z]//g" "$LOG" 2>/dev/null \
|
||||
| grep -viE "building [a-z0-9._-]+ @ file://" \
|
||||
| grep -oiE "building wheel for [a-z0-9._-]+|building [a-z0-9._-]+(==| @ )" \
|
||||
| tr 'A-Z' 'a-z' \
|
||||
| sed -E -e 's/^building wheel for //' -e 's/^building //' -e 's/(==| @ )$//' \
|
||||
| tr '_' '-' \
|
||||
| sort -u || true)"
|
||||
_bad=""
|
||||
for pkg in $_built; do
|
||||
case " $_allow " in *" $pkg "*) continue ;; esac
|
||||
_bad="$_bad $pkg"
|
||||
done
|
||||
if [ -n "$_bad" ]; then
|
||||
fail "built from source:$_bad -- these must resolve to wheels on a clean machine"
|
||||
else
|
||||
[ -n "$_built" ] && say_built="$(echo "$_built" | tr '\n' ' ')" || say_built="none"
|
||||
ok "no non-allowlisted source build (built: $say_built)"
|
||||
fi
|
||||
# Independent of package names: a compiler error means a toolchain was needed.
|
||||
if grep -qiE "error: command '(cc|gcc|clang|cl)' failed|no such file or directory: 'cc'|clang: error|cargo: not found|error: linker \`cc\` not found" "$LOG"; then
|
||||
fail "compiler invocation appears in the install log"
|
||||
grep -iE "error: command '(cc|gcc|clang|cl)' failed|clang: error" "$LOG" | head -10
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
macho)
|
||||
# The one thing masking cannot reproduce: Rosetta 2 is preinstalled on hosted
|
||||
# runners and absent from a factory-fresh Mac, so an x86_64-only payload runs
|
||||
# green here and dies with "bad CPU type in executable" for the user. `lipo` is an
|
||||
# xcrun shim and gone after masking, so read `file -b`, keyed off `uname -m`
|
||||
# (macos-15-intel is x86_64).
|
||||
#
|
||||
# SCOPE: all of $MACHO_ROOT, .venv_t5_510/_530/_550 sidecars included. Those are
|
||||
# payload, not scratch: setup.sh:579-581 creates them during a normal install and
|
||||
# transformers_version.py:338-348 puts them on sys.path. Any exclusion must be a
|
||||
# named path rule, never a narrowed find.
|
||||
root="${MACHO_ROOT:-${UNSLOTH_STUDIO_HOME:-$HOME/.unsloth}}"
|
||||
want="$(uname -m)"
|
||||
[ "$want" = "aarch64" ] && want=arm64
|
||||
if [ ! -d "$root" ]; then
|
||||
fail "macho requested but $root does not exist"
|
||||
else
|
||||
# SCOPE, part 2: the two payloads the install RUNS ON live outside $root.
|
||||
# `uv venv` links <venv>/bin/python at its base interpreter rather than copying
|
||||
# it, and the find below has no -L, so the interpreter that ran every install
|
||||
# step is invisible to it; the uv that fetched it lands in $HOME/.local/bin.
|
||||
# Both are exactly what Rosetta 2 hides: an x86_64 uv or managed CPython runs
|
||||
# green here and dies on the factory-fresh Mac this job stands in for.
|
||||
_macho_targets() {
|
||||
find "$root" -type f \( -perm -u+x -o -name '*.dylib' -o -name '*.so' -o -name '*.node' \) 2>/dev/null
|
||||
# -L follows the interpreter symlink; -maxdepth keeps this a bin/ lookup and
|
||||
# not a second walk of site-packages through the venv's lib64 link. Depth 4
|
||||
# covers <root>/unsloth_studio, the .venv_t5_* sidecars and the tauri layout's
|
||||
# <root>/studio/unsloth_studio.
|
||||
find -L "$root" -maxdepth 4 -type f -path '*/bin/python' 2>/dev/null
|
||||
for _uv in "$HOME/.local/bin/uv" "$(command -v uv 2>/dev/null || true)"; do
|
||||
[ -n "$_uv" ] && [ -f "$_uv" ] && printf '%s\n' "$_uv"
|
||||
done
|
||||
}
|
||||
n=0 nexe=0 nout=0 bad_arch="" unsigned="" broken=""
|
||||
while IFS= read -r f; do
|
||||
desc="$(file -b "$f" 2>/dev/null || true)"
|
||||
case "$desc" in *Mach-O*) ;; *) continue ;; esac
|
||||
n=$((n + 1))
|
||||
case "$f" in "$root"/*) ;; *) nout=$((nout + 1)) ;; esac
|
||||
# Substring, not equality: a universal binary lists every slice it carries,
|
||||
# and one that includes the host arch is fine.
|
||||
case "$desc" in
|
||||
*"$want"*) ;;
|
||||
*) bad_arch="$bad_arch $f [$desc]" ;;
|
||||
esac
|
||||
|
||||
# Signature: MAIN EXECUTABLES ONLY. Asserting it on every Mach-O failed the
|
||||
# mask/pipe leg on 29 ordinary PyPI extension modules plus libportaudio.dylib:
|
||||
# MH_BUNDLE/MH_DYLIB images dlopen'd without library validation, shipped
|
||||
# unsigned, and that run had already imported them with the installer exiting
|
||||
# 0. macOS enforces on main executables and gatekept .app bundles.
|
||||
#
|
||||
# Key off the filetype `file` reports, not the path: a .so may be a bundle or a
|
||||
# dylib, and an executable may have no extension. The library veto is second so
|
||||
# a mixed-type fat file counts as a library. Substring tests are
|
||||
# order-independent (Apple prints `... executable arm64`, GNU `... arm64
|
||||
# executable`).
|
||||
_is_exe=0
|
||||
case "$desc" in *executable*) _is_exe=1 ;; esac
|
||||
case "$desc" in *"shared library"*|*bundle*) _is_exe=0 ;; esac
|
||||
# Named rule so a failure says which path matched; the filetype test
|
||||
# already covers the MH_EXECUTE at .app/Contents/MacOS/<name>.
|
||||
case "$f" in *.app/Contents/MacOS/*) _is_exe=1 ;; esac
|
||||
[ "$_is_exe" = 1 ] && nexe=$((nexe + 1))
|
||||
|
||||
# arm64 only: the kernel refuses to exec an unsigned arm64 main binary
|
||||
# ("Killed: 9"), while x86_64 execs it happily, so an unsigned x86_64
|
||||
# payload is not the same defect.
|
||||
if [ "$want" = "arm64" ] && [ "$_is_exe" = 1 ]; then
|
||||
# Ad-hoc counts as signed: arm64 linkers seal ad-hoc by default, so the test
|
||||
# is "has a seal that verifies", not "has an identity". `spctl` and
|
||||
# `--strict` would demand an authority and reject ad-hoc.
|
||||
if ! codesign -v "$f" >/dev/null 2>&1; then
|
||||
# Nothing to verify and a seal that does not match mean different things.
|
||||
# Captured, not piped into grep: `codesign -dvv` exits non-zero on an
|
||||
# unsigned file, and under `pipefail` that is the pipeline's status even
|
||||
# on a match.
|
||||
_sig="$(codesign -dvv "$f" 2>&1 || true)"
|
||||
case "$_sig" in
|
||||
*"not signed at all"*) unsigned="$unsigned $f" ;;
|
||||
*) broken="$broken $f" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
done < <(_macho_targets | sort -u)
|
||||
if [ "$n" = "0" ]; then
|
||||
# An empty scan reads exactly like a clean one, so a wrong root would pass
|
||||
# and prove nothing.
|
||||
fail "no Mach-O found under $root; the arch/signature assertion proved nothing"
|
||||
elif [ "$nout" = "0" ]; then
|
||||
# Same rule for the roots added above: install.sh always bootstraps uv into
|
||||
# $HOME/.local/bin, so zero hits outside $root means the extra scan matched
|
||||
# nothing and uv's architecture went unproven.
|
||||
fail "no Mach-O outside $root was scanned, so uv and the venv's base interpreter escaped the check"
|
||||
elif [ -n "$bad_arch" ]; then
|
||||
fail "Mach-O is not $want, so it runs here only under Rosetta 2, which a fresh Mac does not have:$bad_arch"
|
||||
elif [ -n "$unsigned" ]; then
|
||||
fail "unsigned Mach-O main executable, which arm64 macOS refuses to exec:$unsigned"
|
||||
elif [ -n "$broken" ]; then
|
||||
fail "Mach-O main executable carries a signature that does not verify:$broken"
|
||||
else
|
||||
ok "$n Mach-O files under $root, plus uv and the venv's base interpreter, are $want$([ "$want" = arm64 ] && echo "; all $nexe main executable(s) signed")"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
fail "unknown check '$check'"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exit "$rc"
|
||||
223
.github/scripts/clean-machine-env.sh
vendored
Executable file
223
.github/scripts/clean-machine-env.sh
vendored
Executable file
|
|
@ -0,0 +1,223 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
#
|
||||
# Simulate a virgin developer machine on a GitHub-hosted runner. Two modes, because
|
||||
# "the tool is absent" and "the installer never called the tool" need different
|
||||
# mechanisms:
|
||||
#
|
||||
# mask Make the toolchain genuinely ABSENT: scrub PATH to OS defaults and (with
|
||||
# --remove) move the real toolchain aside so `command -v git` correctly
|
||||
# FAILS. Deliberately no "poison shims": a failing shim is still FOUND by
|
||||
# `command -v`, which reports the tool as present, the opposite of clean.
|
||||
# trace Leave the toolchain working behind wrappers that log the call then exec
|
||||
# the real binary, answering whether the installer ever REACHES for a
|
||||
# compiler/git without changing behaviour.
|
||||
#
|
||||
# Writes shell exports to $CLEAN_ENV_FILE (default ./clean-machine.env) to `source`;
|
||||
# nothing is exported globally, so other steps keep a normal environment.
|
||||
#
|
||||
# Usage:
|
||||
# bash .github/scripts/clean-machine-env.sh mask [--remove]
|
||||
# bash .github/scripts/clean-machine-env.sh trace
|
||||
# source ./clean-machine.env
|
||||
set -uo pipefail
|
||||
|
||||
MODE="${1:-}"
|
||||
REMOVE=0
|
||||
[ "${2:-}" = "--remove" ] && REMOVE=1
|
||||
|
||||
case "$MODE" in
|
||||
mask|trace) ;;
|
||||
*) echo "usage: $0 {mask|trace} [--remove]" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
OS="$(uname -s)"
|
||||
WORK="${CLEAN_MACHINE_DIR:-$PWD/.clean-machine}"
|
||||
ENV_FILE="${CLEAN_ENV_FILE:-$PWD/clean-machine.env}"
|
||||
TRACE="$WORK/tool-invocations.log"
|
||||
BIN="$WORK/bin"
|
||||
RESTORE="$WORK/restore.sh"
|
||||
mkdir -p "$BIN"
|
||||
: > "$TRACE"
|
||||
: > "$ENV_FILE"
|
||||
printf '#!/usr/bin/env bash\n# Undo clean-machine-env.sh --remove. Safe to run twice.\nset -uo pipefail\n' > "$RESTORE"
|
||||
chmod +x "$RESTORE"
|
||||
|
||||
# The toolchain we care about: a consumer install must need none of it.
|
||||
TOOLS="xcode-select xcrun clang clang++ cc c++ gcc g++ git cmake make brew ninja cargo rustc"
|
||||
|
||||
note() { echo "[clean-machine] $*"; }
|
||||
|
||||
# Move a path aside and record the reverse in restore.sh. PATH scrubbing only HIDES
|
||||
# these; uv, the py launcher and framework lookups find them regardless, so absence
|
||||
# has to be real. The restore line is guarded: the install may have recreated the
|
||||
# path, and an unguarded `mv` would bury the original inside it.
|
||||
mask_aside() {
|
||||
local src="$1" dst="${2:-$1.masked}" as=""
|
||||
[ -e "$src" ] || return 0
|
||||
[ -w "$(dirname "$src")" ] || as="sudo"
|
||||
if $as mv "$src" "$dst" 2>/dev/null; then
|
||||
note "moved $src aside"
|
||||
printf "[ -e '%s' ] || %s mv '%s' '%s' 2>/dev/null || true\n" "$src" "$as" "$dst" "$src" >> "$RESTORE"
|
||||
else
|
||||
note "WARN could not move $src"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── PATH scrub ────────────────────────────────────────────────────────────────
|
||||
# Keep only OS-default system dirs: drops Homebrew, the hosted Python toolcache,
|
||||
# setup-* shims, pipx, cargo and every other preinstalled developer dir.
|
||||
scrub_path() {
|
||||
local keep out=""
|
||||
if [ "$OS" = "Darwin" ]; then
|
||||
keep="/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
else
|
||||
keep="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
fi
|
||||
local IFS=":"
|
||||
for d in $keep; do
|
||||
[ -d "$d" ] && out="${out:+$out:}$d"
|
||||
done
|
||||
echo "$out"
|
||||
}
|
||||
|
||||
# ── mask ──────────────────────────────────────────────────────────────────────
|
||||
if [ "$MODE" = "mask" ]; then
|
||||
NEWPATH="$(scrub_path)"
|
||||
{
|
||||
echo "export PATH='$NEWPATH'"
|
||||
# UNSET, not a fake path: `xcode-select -p` honours DEVELOPER_DIR and prints it
|
||||
# verbatim with exit 0, so a nonexistent dir makes the probe SUCCEED. On a clean
|
||||
# Mac it is unset and the missing xcode_select_link is what makes the probe fail.
|
||||
echo "unset DEVELOPER_DIR || true"
|
||||
echo "unset SDKROOT CC CXX CFLAGS CXXFLAGS LDFLAGS CMAKE_GENERATOR CMAKE_PREFIX_PATH || true"
|
||||
echo "export HOMEBREW_NO_AUTO_UPDATE=1"
|
||||
echo "export UNSLOTH_CLEAN_MACHINE=1"
|
||||
} >> "$ENV_FILE"
|
||||
|
||||
if [ "$REMOVE" = "1" ] && [ "$OS" = "Darwin" ]; then
|
||||
# Best effort, each step independent and recorded in restore.sh so an
|
||||
# `if: always()` step can put the runner back. `xcode-select -p` reads
|
||||
# xcode_select_link, so removing it reproduces a virgin Mac's gate;
|
||||
# `xcode-select --reset` is NOT enough, it can reselect a full Xcode.app.
|
||||
if [ -e /var/db/xcode_select_link ]; then
|
||||
if sudo rm -f /var/db/xcode_select_link 2>/dev/null; then
|
||||
note "removed /var/db/xcode_select_link"
|
||||
echo "sudo xcode-select --switch /Library/Developer/CommandLineTools 2>/dev/null || true" >> "$RESTORE"
|
||||
else
|
||||
note "WARN could not remove /var/db/xcode_select_link"
|
||||
fi
|
||||
fi
|
||||
# Moving the CLT dir aside turns /usr/bin/{cc,clang,git} into dead shims, so the
|
||||
# run also proves the install needs no compiler at all.
|
||||
if [ -d /Library/Developer/CommandLineTools ]; then
|
||||
if sudo mv /Library/Developer/CommandLineTools /Library/Developer/CommandLineTools.masked 2>/dev/null; then
|
||||
note "moved CommandLineTools aside"
|
||||
echo "sudo mv /Library/Developer/CommandLineTools.masked /Library/Developer/CommandLineTools 2>/dev/null || true" >> "$RESTORE"
|
||||
else
|
||||
note "WARN could not move CommandLineTools"
|
||||
fi
|
||||
fi
|
||||
# Xcode.app must go too: with the link removed AND CommandLineTools moved,
|
||||
# `xcode-select -p` still succeeds, falling through to the image's Xcode bundle
|
||||
# (observed: /Applications/Xcode_16.4.app/Contents/Developer), which re-arms
|
||||
# /usr/bin/{git,cc}. A rename is instant whatever the bundle size.
|
||||
for app in /Applications/Xcode*.app; do
|
||||
[ -d "$app" ] || continue
|
||||
if sudo mv "$app" "${app}.masked" 2>/dev/null; then
|
||||
note "moved $(basename "$app") aside"
|
||||
echo "sudo mv '${app}.masked' '$app' 2>/dev/null || true" >> "$RESTORE"
|
||||
else
|
||||
note "WARN could not move $app"
|
||||
fi
|
||||
done
|
||||
# /usr/local EXISTS on a factory-fresh Mac: a SIP-exempt firmlink, and empty. What
|
||||
# is absent is its CONTENTS, /usr/local/bin included, so empty it rather than remove
|
||||
# it. Before the Homebrew block below, so /usr/local/Homebrew is stashed once, with
|
||||
# one restore line, in the right order.
|
||||
if [ -d /usr/local ]; then
|
||||
STASH="$WORK/usr-local"
|
||||
mkdir -p "$STASH"
|
||||
for entry in /usr/local/* /usr/local/.[!.]*; do
|
||||
[ -e "$entry" ] || continue
|
||||
base="$(basename "$entry")"
|
||||
if sudo mv "$entry" "$STASH/$base" 2>/dev/null; then
|
||||
note "emptied /usr/local/$base"
|
||||
printf "[ -e '/usr/local/%s' ] || sudo mv '%s/%s' '/usr/local/%s' 2>/dev/null || true\n" \
|
||||
"$base" "$STASH" "$base" "$base" >> "$RESTORE"
|
||||
else
|
||||
note "WARN could not move $entry"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
# The hosted toolcache and the python.org framework are what a PATH scrub cannot
|
||||
# reach: uv discovers interpreters by probing well-known locations.
|
||||
mask_aside "${AGENT_TOOLSDIRECTORY:-$HOME/hostedtoolcache}"
|
||||
mask_aside /Library/Frameworks/Python.framework
|
||||
# Developer dotdirs and caches. A virgin $HOME has none of these, and a populated
|
||||
# uv/pip cache can satisfy a resolution that would fail on a user's machine.
|
||||
for d in .cargo .rustup .nvm .rbenv .pyenv .local .cache \
|
||||
Library/Caches/uv Library/Caches/pip Library/Caches/Homebrew; do
|
||||
mask_aside "$HOME/$d"
|
||||
done
|
||||
for brewdir in /opt/homebrew /usr/local/Homebrew; do
|
||||
if [ -d "$brewdir" ]; then
|
||||
if sudo mv "$brewdir" "${brewdir}.masked" 2>/dev/null; then
|
||||
note "moved $brewdir aside"
|
||||
echo "sudo mv '${brewdir}.masked' '$brewdir' 2>/dev/null || true" >> "$RESTORE"
|
||||
else
|
||||
note "WARN could not move $brewdir"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$REMOVE" = "1" ] && [ "$OS" = "Linux" ]; then
|
||||
# A hosted Linux runner keeps git, gcc, cmake and make in /usr/bin, which the PATH
|
||||
# scrub has to keep, so absence must be made real: move the resolved binaries aside
|
||||
# (recorded in restore.sh). Versioned siblings like gcc-11 survive, but a consumer
|
||||
# install invokes the unsuffixed names, which is what `absent` checks.
|
||||
for tool in $TOOLS; do
|
||||
# Repeat per tool: a runner can carry the same name in /usr/bin and
|
||||
# /usr/local/bin, and moving only the first leaves the second on PATH.
|
||||
for _ in 1 2 3 4; do
|
||||
real="$(command -v "$tool" 2>/dev/null || true)"
|
||||
[ -n "$real" ] && [ -e "$real" ] || break
|
||||
if sudo mv "$real" "$real.masked" 2>/dev/null; then
|
||||
note "moved $real aside"
|
||||
echo "sudo mv '$real.masked' '$real' 2>/dev/null || true" >> "$RESTORE"
|
||||
else
|
||||
note "WARN could not move $real"
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── trace ─────────────────────────────────────────────────────────────────────
|
||||
if [ "$MODE" = "trace" ]; then
|
||||
for tool in $TOOLS; do
|
||||
real="$(command -v "$tool" 2>/dev/null || true)"
|
||||
[ -n "$real" ] || continue
|
||||
# Logs the call then execs the REAL binary: behaviour unchanged, so the trace
|
||||
# answers "did the installer reach for this?" honestly.
|
||||
cat > "$BIN/$tool" <<WRAP
|
||||
#!/bin/sh
|
||||
printf '%s\t%s\n' "$tool" "\$*" >> "$TRACE"
|
||||
exec "$real" "\$@"
|
||||
WRAP
|
||||
chmod +x "$BIN/$tool"
|
||||
done
|
||||
{
|
||||
echo "export PATH='$BIN:$PATH'"
|
||||
echo "export UNSLOTH_TOOL_TRACE='$TRACE'"
|
||||
echo "export UNSLOTH_CLEAN_MACHINE=trace"
|
||||
} >> "$ENV_FILE"
|
||||
fi
|
||||
|
||||
note "mode=$MODE remove=$REMOVE"
|
||||
note "env file: $ENV_FILE"
|
||||
note "trace: $TRACE"
|
||||
note "restore: $RESTORE"
|
||||
41
.github/scripts/ensure-docker-daemon.ps1
vendored
Normal file
41
.github/scripts/ensure-docker-daemon.ps1
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Waits for the Windows Docker daemon on a hosted runner, starting the service if
|
||||
# it is installed but not running.
|
||||
#
|
||||
# Docker is installed on every windows-2022 image (runner-images uses Microsoft's
|
||||
# install-docker-ce.ps1 without -HyperV, so the daemon serves WINDOWS containers) but
|
||||
# is not always RUNNING when a job starts: a spike run died 21s in with
|
||||
# failed to connect to the docker API at npipe:////./pipe/docker_engine
|
||||
# while a sibling job was fine. Without this wait that flake reads as "Windows
|
||||
# containers are not available on hosted runners", the wrong conclusion entirely.
|
||||
|
||||
[CmdletBinding()]
|
||||
param([int] $TimeoutMinutes = 5)
|
||||
|
||||
$deadline = (Get-Date).AddMinutes($TimeoutMinutes)
|
||||
while ($true) {
|
||||
docker info *>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "docker daemon is up"
|
||||
break
|
||||
}
|
||||
if ((Get-Date) -ge $deadline) {
|
||||
Write-Host "::error::the Docker daemon never became reachable within $TimeoutMinutes minutes"
|
||||
Get-Service docker -ErrorAction SilentlyContinue | Format-List | Out-String | Write-Host
|
||||
exit 1
|
||||
}
|
||||
$svc = Get-Service -Name docker -ErrorAction SilentlyContinue
|
||||
Write-Host "docker service status: $(if ($svc) { $svc.Status } else { 'NOT INSTALLED' }); retrying..."
|
||||
if ($svc -and $svc.Status -ne 'Running') {
|
||||
Start-Service docker -ErrorAction SilentlyContinue
|
||||
}
|
||||
Start-Sleep -Seconds 5
|
||||
}
|
||||
|
||||
# The failing `docker info` probes leave $LASTEXITCODE non-zero and the runner appends
|
||||
# `exit $LASTEXITCODE` to every pwsh step (actions/runner#351), so without this reset a
|
||||
# successful wait still fails the step.
|
||||
$global:LASTEXITCODE = 0
|
||||
exit 0
|
||||
154
.github/scripts/virgin-windows-install.ps1
vendored
Normal file
154
.github/scripts/virgin-windows-install.ps1
vendored
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Runs INSIDE a Windows container, after virgin-windows-probe.ps1 has proved there is no
|
||||
# toolchain: install.ps1 the way a real user on a bare Windows box runs it, then the same
|
||||
# assertions the hosted Windows leg makes.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $Installer = 'C:\ci\install.ps1',
|
||||
[string] $LogPath = 'C:\ci-out\install.log',
|
||||
[string] $Overlay = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LogPath) | Out-Null
|
||||
|
||||
function Section($t) { Write-Host ""; Write-Host "=== $t ===" }
|
||||
|
||||
# ── Environment the installer needs to be non-interactive ─────────────────────
|
||||
Section 'install environment'
|
||||
# install.ps1:2885-2888 prompts `Start Unsloth Studio now? [Y/n]` when
|
||||
# [Environment]::UserInteractive is true and stdin is not redirected -- both hold under
|
||||
# `docker exec` -- so without this the installer BLOCKS FOREVER on Read-Host and the job
|
||||
# dies on timeout with no diagnosis.
|
||||
$env:UNSLOTH_SKIP_AUTOSTART = '1'
|
||||
# install.ps1:254/258 joins $env:USERPROFILE with no null guard. An explicit root also
|
||||
# keeps the container's state under one directory.
|
||||
$env:UNSLOTH_STUDIO_HOME = 'C:\studio-home'
|
||||
$env:UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK = '1'
|
||||
# Without this uv's output is discarded on success and the nobuild check below can only
|
||||
# ever report "built: none".
|
||||
$env:UNSLOTH_VERBOSE = '1'
|
||||
if ($Overlay) {
|
||||
$env:UNSLOTH_CI_SOURCE_OVERLAY = $Overlay
|
||||
Write-Host "overlay: $Overlay"
|
||||
} else {
|
||||
Remove-Item Env:\UNSLOTH_CI_SOURCE_OVERLAY -ErrorAction SilentlyContinue
|
||||
Write-Host "overlay: (none -- tests the released wheel)"
|
||||
}
|
||||
foreach ($v in 'UNSLOTH_STUDIO_HOME', 'UNSLOTH_SKIP_AUTOSTART', 'UNSLOTH_VERBOSE', 'UNSLOTH_CI_SOURCE_OVERLAY') {
|
||||
Write-Host (" {0,-32} {1}" -f $v, [System.Environment]::GetEnvironmentVariable($v))
|
||||
}
|
||||
|
||||
# NOTE: deliberately NOT setting [Net.ServicePointManager]::SecurityProtocol here.
|
||||
# install.ps1 does not set it either, so setting it in the harness would hide a real
|
||||
# installer bug. The probe already reported whether the default negotiates TLS 1.2.
|
||||
|
||||
# ── Run the installer exactly as the desktop launches it ──────────────────────
|
||||
Section 'install'
|
||||
if (-not (Test-Path -LiteralPath $Installer)) {
|
||||
Write-Host "::error::installer not found at $Installer"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "installer: $Installer ($((Get-Content -LiteralPath $Installer).Count) lines)"
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
# A child powershell.exe, not dot-sourcing: same shape as install.rs:325-339, and it
|
||||
# gives a real process exit code instead of whatever the last statement returned.
|
||||
& powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass `
|
||||
-File $Installer *>&1 | Tee-Object -FilePath $LogPath
|
||||
$rc = $LASTEXITCODE
|
||||
$sw.Stop()
|
||||
Write-Host ""
|
||||
Write-Host "installer exit code: $rc (after $([int]$sw.Elapsed.TotalSeconds)s)"
|
||||
|
||||
# ── Assertions ────────────────────────────────────────────────────────────────
|
||||
$failures = @()
|
||||
$venv = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio'
|
||||
$venvPy = Join-Path $venv 'Scripts\python.exe'
|
||||
|
||||
Section 'assert: the install produced something usable'
|
||||
if ($rc -ne 0) {
|
||||
$failures += "installer exited $rc"
|
||||
} else {
|
||||
# As the Linux leg: an installer that exits 0 having done nothing must not pass.
|
||||
if (-not (Test-Path -LiteralPath $venvPy)) {
|
||||
$failures += "installer exited 0 but left no managed Python at $venvPy"
|
||||
Get-ChildItem -Path $env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue | Format-Table | Out-String | Write-Host
|
||||
} else {
|
||||
Write-Host "managed python: $venvPy"
|
||||
& $venvPy -V
|
||||
}
|
||||
foreach ($cli in (Join-Path $venv 'Scripts\unsloth.exe'), (Join-Path $env:UNSLOTH_STUDIO_HOME 'bin\unsloth.exe')) {
|
||||
if (Test-Path -LiteralPath $cli) { Write-Host "unsloth CLI: $cli" }
|
||||
else { $failures += "installer exited 0 but left no unsloth CLI at $cli" }
|
||||
}
|
||||
}
|
||||
|
||||
Section 'assert: torch imports'
|
||||
# On the hosted runner this proves less than it looks: the image ships the VC++
|
||||
# 2015-2022 runtime in System32, so Test-VCRedistInstalled (setup.ps1:875)
|
||||
# short-circuits before it needs winget. THIS container is the first environment where
|
||||
# that is not true, so a failure here is a genuine finding about bare Windows.
|
||||
if (Test-Path -LiteralPath $venvPy) {
|
||||
foreach ($dll in 'vcruntime140.dll', 'vcruntime140_1.dll', 'msvcp140.dll') {
|
||||
$p = Join-Path $env:WINDIR "System32\$dll"
|
||||
Write-Host (" System32\{0,-20} {1}" -f $dll, $(if (Test-Path $p) { 'PRESENT' } else { 'ABSENT' }))
|
||||
}
|
||||
& $venvPy -c "import ctypes.util; print('find_library(vcruntime140):', ctypes.util.find_library('vcruntime140'))"
|
||||
& $venvPy -c "import torch; print('torch', torch.__version__)"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$failures += "torch failed to import from the managed Python (VC++ runtime missing?)"
|
||||
}
|
||||
$global:LASTEXITCODE = 0
|
||||
} else {
|
||||
Write-Host "skipped: no managed Python"
|
||||
}
|
||||
|
||||
Section "assert: the installer took the no-winget path"
|
||||
if (Test-Path -LiteralPath $LogPath) {
|
||||
# install.ps1:1098, the no-winget branch. A container has no Microsoft Store and so
|
||||
# no App Installer, which puts the fallback path (python.org + astral.sh) under
|
||||
# test -- the whole reason a container is a good harness.
|
||||
$noWinget = 'will require Python + uv to be already installed'
|
||||
if (Select-String -Path $LogPath -Pattern $noWinget -SimpleMatch -Quiet) {
|
||||
Write-Host "confirmed: installer reported winget as unavailable and used the fallback path"
|
||||
} else {
|
||||
$failures += "installer never reported winget as unavailable; it did not take the no-winget path"
|
||||
}
|
||||
}
|
||||
|
||||
if ($Overlay -and $rc -eq 0) {
|
||||
Section 'assert: this ref was really put under test'
|
||||
if (Select-String -Path $LogPath -Pattern 'CI: overlaying source checkout' -SimpleMatch -Quiet) {
|
||||
Write-Host "overlay applied; this leg exercised this ref's Python"
|
||||
} else {
|
||||
$failures += "leg is marked overlay but the installer never overlaid the checkout, so it only tested the released package"
|
||||
}
|
||||
}
|
||||
|
||||
Section 'assert: no non-allowlisted source build'
|
||||
# Shared with the hosted Windows legs so the sdist allowlist lives in one place; it
|
||||
# prints its own diagnosis, so only the verdict is folded in here.
|
||||
$nobuild = Join-Path $PSScriptRoot 'assert-nobuild.ps1'
|
||||
if (-not (Test-Path -LiteralPath $nobuild)) {
|
||||
$failures += "assert-nobuild.ps1 is missing next to this script, so the no-build contract went unchecked"
|
||||
} else {
|
||||
& $nobuild -LogPath $LogPath
|
||||
if ($LASTEXITCODE -ne 0) { $failures += "a non-allowlisted source build appears in the install log" }
|
||||
}
|
||||
|
||||
# ── Verdict ───────────────────────────────────────────────────────────────────
|
||||
Section 'verdict'
|
||||
if ($failures.Count -gt 0) {
|
||||
Write-Host "---- last 60 lines of the install log ----"
|
||||
Get-Content -LiteralPath $LogPath -Tail 60 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host "-----------------------------------------"
|
||||
foreach ($f in $failures) { Write-Host "::error::$f" }
|
||||
Write-Host "VIRGIN WINDOWS CONTAINER INSTALL FAILED ($($failures.Count) problem(s))"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "VIRGIN WINDOWS CONTAINER INSTALL PASSED"
|
||||
exit 0
|
||||
178
.github/scripts/virgin-windows-probe.ps1
vendored
Normal file
178
.github/scripts/virgin-windows-probe.ps1
vendored
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Runs INSIDE a Windows container, proving the environment is genuinely virgin BEFORE
|
||||
# anything is installed into it. This is the entire point of the container lane: the
|
||||
# hosted-runner Windows legs of clean-machine-install-ci.yml only simulate absence
|
||||
# (rename the toolcache Python dir, scrub the Machine and User registry PATH), while
|
||||
# this asserts real absence on an image that never had a toolchain. Without it the lane
|
||||
# proves nothing the masked legs did not already prove.
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$failures = @()
|
||||
|
||||
function Section($t) { Write-Host ""; Write-Host "=== $t ===" }
|
||||
|
||||
# ── The interpreter itself ────────────────────────────────────────────────────
|
||||
# install.ps1 must run under Windows PowerShell 5.1, which is what a real Windows
|
||||
# box ships. pwsh 7 is a runner-image extra no user is promised. nanoserver has
|
||||
# NEITHER, which is why this lane is on servercore.
|
||||
Section 'interpreter'
|
||||
Write-Host "PSVersion : $($PSVersionTable.PSVersion)"
|
||||
Write-Host "PSEdition : $($PSVersionTable.PSEdition)"
|
||||
Write-Host "CLRVersion : $($PSVersionTable.CLRVersion)"
|
||||
Write-Host "Host : $($Host.Name)"
|
||||
if ($PSVersionTable.PSEdition -ne 'Desktop') {
|
||||
$failures += "PSEdition is '$($PSVersionTable.PSEdition)', not Desktop -- this is not Windows PowerShell 5.1"
|
||||
}
|
||||
if ($PSVersionTable.PSVersion.Major -ne 5) {
|
||||
$failures += "PSVersion is $($PSVersionTable.PSVersion), not 5.x"
|
||||
}
|
||||
|
||||
Section 'operating system'
|
||||
cmd /c ver
|
||||
$cv = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction SilentlyContinue
|
||||
if ($cv) {
|
||||
Write-Host "ProductName : $($cv.ProductName)"
|
||||
Write-Host "EditionID : $($cv.EditionID)"
|
||||
Write-Host "InstallationType: $($cv.InstallationType)"
|
||||
Write-Host "CurrentBuild : $($cv.CurrentBuild).$($cv.UBR)"
|
||||
}
|
||||
Write-Host "USERNAME : $env:USERNAME"
|
||||
Write-Host "USERPROFILE : $env:USERPROFILE"
|
||||
Write-Host "LOCALAPPDATA : $env:LOCALAPPDATA"
|
||||
Write-Host "PROCESSOR_ARCH : $env:PROCESSOR_ARCHITECTURE"
|
||||
|
||||
# install.ps1:254/258 does Join-Path $env:USERPROFILE ".unsloth\studio" with no null
|
||||
# guard, so an unset USERPROFILE aborts under ErrorActionPreference=Stop. The lane sets
|
||||
# UNSLOTH_STUDIO_HOME, but record whether a bare container would have survived without.
|
||||
if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
|
||||
Write-Host "::warning::USERPROFILE is unset in this container; install.ps1's default install root would abort"
|
||||
}
|
||||
|
||||
# ── The assertion the whole lane exists for ───────────────────────────────────
|
||||
Section 'virginity: developer toolchain must be ABSENT'
|
||||
# python/py/git/cmake/cl/winget are the six the task names. uv is added because
|
||||
# install.ps1 would happily reuse a preinstalled one and skip its own bootstrap.
|
||||
$mustBeAbsent = @('python', 'python3', 'py', 'git', 'cmake', 'cl', 'winget', 'uv')
|
||||
foreach ($t in $mustBeAbsent) {
|
||||
$c = Get-Command $t -ErrorAction SilentlyContinue
|
||||
$where = if ($c) { $c.Source } else { 'ABSENT' }
|
||||
Write-Host (" {0,-10} {1}" -f $t, $where)
|
||||
if ($c) { $failures += "$t is present at $($c.Source) -- this container is NOT virgin" }
|
||||
}
|
||||
|
||||
Section 'informational: present but not a developer toolchain'
|
||||
# OS components, not a toolchain. curl.exe and tar.exe ship in System32 on Server 2022
|
||||
# and are the only transport into a container with no git; naming them keeps the
|
||||
# premise honest rather than silently relying on them.
|
||||
foreach ($t in 'cmd', 'powershell', 'curl', 'tar', 'certutil', 'msiexec', 'reg', 'where', 'pwsh', 'node', 'npm', 'msbuild', 'dotnet', 'gcc') {
|
||||
$c = Get-Command $t -ErrorAction SilentlyContinue
|
||||
Write-Host (" {0,-10} {1}" -f $t, $(if ($c) { $c.Source } else { 'ABSENT' }))
|
||||
}
|
||||
|
||||
Section 'virginity: no toolchain on disk either'
|
||||
# A binary can be off PATH and still be found by uv's interpreter discovery or py.exe's
|
||||
# registry view -- exactly how the hosted Windows leg once reported `python ABSENT` and
|
||||
# then installed with the runner's 3.13.14. So check disk and registry too.
|
||||
$badPaths = @(
|
||||
'C:\Python27', 'C:\Python3*', 'C:\Program Files\Python*', 'C:\Program Files (x86)\Python*',
|
||||
'C:\Program Files\Git', 'C:\Program Files\CMake', 'C:\Program Files\Microsoft Visual Studio',
|
||||
'C:\Program Files (x86)\Microsoft Visual Studio', 'C:\hostedtoolcache', 'C:\ProgramData\chocolatey'
|
||||
)
|
||||
foreach ($p in $badPaths) {
|
||||
# Wildcards can match several dirs; take the first so the message names a real
|
||||
# path instead of stringifying an array.
|
||||
$hit = @(Get-Item -Path $p -ErrorAction SilentlyContinue) | Select-Object -First 1
|
||||
if ($hit) {
|
||||
Write-Host " PRESENT $($hit.FullName)"
|
||||
$failures += "toolchain directory exists on disk: $($hit.FullName)"
|
||||
} else {
|
||||
Write-Host " absent $p"
|
||||
}
|
||||
}
|
||||
|
||||
$pyReg = @('HKLM:\SOFTWARE\Python', 'HKCU:\SOFTWARE\Python')
|
||||
foreach ($k in $pyReg) {
|
||||
if (Test-Path $k) {
|
||||
Write-Host " PRESENT $k"
|
||||
$failures += "a registered Python install exists at $k"
|
||||
} else {
|
||||
Write-Host " absent $k"
|
||||
}
|
||||
}
|
||||
|
||||
Section 'PATH as the container sees it'
|
||||
Write-Host "Process PATH:"
|
||||
($env:PATH -split ';') | Where-Object { $_ } | ForEach-Object { Write-Host " $_" }
|
||||
foreach ($scope in 'Machine', 'User') {
|
||||
Write-Host "$scope PATH: $([System.Environment]::GetEnvironmentVariable('Path', $scope))"
|
||||
}
|
||||
|
||||
# ── The VC++ runtime question the hosted leg cannot answer ────────────────────
|
||||
Section 'VC++ runtime (honest measurement)'
|
||||
# The hosted image ships the VC++ 2015-2022 runtime in System32 and cannot lose it
|
||||
# without breaking the runner (see the HONESTY NOTE in clean-machine-install-ci.yml),
|
||||
# so `import torch` succeeding there does NOT prove a no-winget machine has the
|
||||
# runtime. This container is the only environment in CI that can answer it, so their
|
||||
# absence is asserted, not merely recorded: if a future base image starts shipping
|
||||
# them the lane silently degrades into another masked leg.
|
||||
foreach ($dll in 'vcruntime140.dll', 'vcruntime140_1.dll', 'msvcp140.dll') {
|
||||
$p = Join-Path $env:WINDIR "System32\$dll"
|
||||
$present = Test-Path $p
|
||||
Write-Host (" {0,-20} {1}" -f $dll, $(if ($present) { 'PRESENT' } else { 'ABSENT' }))
|
||||
if ($present) { $failures += "System32\$dll is present -- this image already ships the VC++ runtime, which is the one thing the hosted runner cannot un-ship" }
|
||||
}
|
||||
foreach ($k in 'HKLM:\SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64',
|
||||
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\x64') {
|
||||
$r = Get-ItemProperty $k -ErrorAction SilentlyContinue
|
||||
Write-Host (" {0} -> {1}" -f $k, $(if ($r) { "Installed=$($r.Installed) $($r.Major).$($r.Minor)" } else { 'absent' }))
|
||||
}
|
||||
|
||||
# ── Can the installer's transport work at all here? ───────────────────────────
|
||||
Section 'outbound HTTPS and TLS'
|
||||
# install.ps1 never sets [Net.ServicePointManager]::SecurityProtocol, so it inherits the
|
||||
# .NET Framework default. Test the DEFAULT first: if that fails and Tls12 works, the
|
||||
# installer has a real portability bug on hardened images, not a container quirk.
|
||||
Write-Host "default SecurityProtocol: $([Net.ServicePointManager]::SecurityProtocol)"
|
||||
$probeUrls = @(
|
||||
'https://www.python.org/ftp/python/',
|
||||
'https://astral.sh/uv/install.ps1',
|
||||
'https://pypi.org/simple/',
|
||||
'https://aka.ms/vs/17/release/vc_redist.x64.exe'
|
||||
)
|
||||
$defaultOk = @{}
|
||||
foreach ($u in $probeUrls) {
|
||||
try {
|
||||
$null = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 60 -Method Head -ErrorAction Stop
|
||||
Write-Host " OK (default TLS) $u"; $defaultOk[$u] = $true
|
||||
} catch {
|
||||
Write-Host " FAIL (default TLS) $u -- $($_.Exception.Message)"; $defaultOk[$u] = $false
|
||||
}
|
||||
}
|
||||
if ($defaultOk.Values -contains $false) {
|
||||
Write-Host "retrying the failures with an explicit Tls12..."
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
foreach ($u in $probeUrls) {
|
||||
if ($defaultOk[$u]) { continue }
|
||||
try {
|
||||
$null = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 60 -Method Head -ErrorAction Stop
|
||||
Write-Host " OK (Tls12) $u"
|
||||
Write-Host "::warning::$u needs an explicit Tls12; install.ps1 never sets SecurityProtocol, so this is a real installer portability gap"
|
||||
} catch {
|
||||
Write-Host " FAIL (Tls12) $u -- $($_.Exception.Message)"
|
||||
$failures += "no outbound HTTPS to $u even with Tls12 -- the container cannot reach the installer's download hosts"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ── Verdict ───────────────────────────────────────────────────────────────────
|
||||
Section 'verdict'
|
||||
if ($failures.Count -gt 0) {
|
||||
foreach ($f in $failures) { Write-Host "::error::$f" }
|
||||
Write-Host "VIRGINITY ASSERTION FAILED ($($failures.Count) problem(s))"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "VIRGINITY ASSERTION PASSED"
|
||||
Write-Host "no python, py, git, cmake, cl, winget or uv on PATH, on disk, or in the registry"
|
||||
exit 0
|
||||
1728
.github/workflows/clean-machine-install-ci.yml
vendored
Normal file
1728
.github/workflows/clean-machine-install-ci.yml
vendored
Normal file
File diff suppressed because it is too large
Load diff
740
.github/workflows/desktop-app-clean-machine-ci.yml
vendored
Normal file
740
.github/workflows/desktop-app-clean-machine-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,740 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Installs and launches the SHIPPED desktop app on a machine stripped of developer
|
||||
# tooling, on all three platforms.
|
||||
#
|
||||
# studio-tauri-smoke.yml compiles the Tauri crate and release-desktop.yml produces the
|
||||
# bundles, but neither puts a published artifact on a clean machine and checks that it
|
||||
# starts -- the gap the reported failures fell through: both came from the packaged app
|
||||
# running its bundled Contents/Resources/install.sh, which no CI job exercised.
|
||||
#
|
||||
# Hosted runners have no interactive desktop session, so "runs" means: the bundle
|
||||
# installs, the binary is present, of the right architecture, and clears the gatekeeper
|
||||
# checks a user hits (macOS quarantine + codesign, Windows installer exit); the process
|
||||
# STAYS UP past its preflight, where an unhappy app dies; and it writes tauri.log with a
|
||||
# preflight disposition, the field that read `ManagedReady` over an unbootable venv in the
|
||||
# bug report. Linux gets the strongest check: a real webview under Xvfb.
|
||||
|
||||
name: Desktop app clean machine
|
||||
|
||||
on:
|
||||
# Also on PRs touching this job or the stripping scripts: dispatch resolves the workflow
|
||||
# from the DEFAULT branch, so a new or edited file on a feature branch can never be
|
||||
# dispatched and would first run only after merging blind.
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/desktop-app-clean-machine-ci.yml'
|
||||
- '.github/scripts/clean-machine-env.sh'
|
||||
- '.github/scripts/clean-machine-assert.sh'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release tag in the desktop release repo'
|
||||
type: string
|
||||
default: 'desktop-v0.1.50-beta'
|
||||
release_repo:
|
||||
description: 'owner/name hosting the desktop release'
|
||||
type: string
|
||||
default: ''
|
||||
strip_toolchain:
|
||||
description: 'Strip developer tooling before installing'
|
||||
type: boolean
|
||||
default: true
|
||||
schedule:
|
||||
# Nightly, so a broken published bundle is caught without anyone asking.
|
||||
- cron: '17 5 * * *'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
# Drafts are listed only to a token with push access, and every desktop-v* release here
|
||||
# is a draft, so `contents: read` cannot see the bundle under test at all.
|
||||
contents: write
|
||||
|
||||
env:
|
||||
# release-desktop.yml publishes into github.repository, so a nightly aimed anywhere else
|
||||
# goes green over a broken production bundle. unsloth-test/unsloth-test holds one frozen
|
||||
# release, so the schedule was re-testing the same fixture forever.
|
||||
REL_REPO: ${{ inputs.release_repo || github.repository }}
|
||||
# Empty unless dispatched: a pinned tag is an immutable fixture, so a nightly against it
|
||||
# could never catch a newly published broken bundle. Each download step then resolves
|
||||
# the newest desktop-v* release, drafts included -- every desktop-v* release here is cut
|
||||
# as a draft, so --exclude-drafts matched nothing and every leg died resolving.
|
||||
# releases/tags/<tag> 404s for a draft, but gh looks drafts up over GraphQL, so `gh
|
||||
# release download <tag>` still fetches their assets.
|
||||
REL_TAG: ${{ inputs.release_tag || '' }}
|
||||
UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
|
||||
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
|
||||
|
||||
jobs:
|
||||
# ── macOS: .dmg, Apple Silicon ────────────────────────────────────────────
|
||||
macos:
|
||||
# A fork PR's token is read-only however this workflow declares permissions, so it
|
||||
# cannot list the draft releases every desktop-v* bundle is published as. Skip rather
|
||||
# than fail: a property of the trigger, not a broken release.
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
name: desktop macOS ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
continue-on-error: ${{ matrix.experimental }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- {os: macos-14, experimental: false}
|
||||
- {os: macos-15, experimental: false}
|
||||
- {os: macos-26, experimental: true}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download the shipped .dmg
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p dl logs
|
||||
# Desktop releases are prereleases (never repo-wide "latest") and drafts, so the
|
||||
# newest desktop-v* tag has to be resolved explicitly. See REL_TAG above.
|
||||
if [ -z "$REL_TAG" ]; then
|
||||
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \
|
||||
--json tagName,createdAt \
|
||||
--jq '[.[] | select(.tagName | startswith("desktop-v"))]
|
||||
| sort_by(.createdAt) | reverse | .[0].tagName // empty')"
|
||||
# Loud on purpose: there is no bundle to test, so passing would prove nothing.
|
||||
[ -n "$REL_TAG" ] || {
|
||||
echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)"
|
||||
exit 1
|
||||
}
|
||||
echo "resolved release tag: $REL_TAG"
|
||||
echo "REL_TAG=$REL_TAG" >> "$GITHUB_ENV"
|
||||
fi
|
||||
gh release download "$REL_TAG" --repo "$REL_REPO" \
|
||||
--pattern '*aarch64.dmg' --dir dl
|
||||
ls -la dl
|
||||
|
||||
- name: Strip the developer toolchain
|
||||
# `inputs` exists only for workflow_dispatch, so elsewhere strip_toolchain is '' --
|
||||
# and loose equality coerces both '' and false to 0, making `!= false` FALSE, so
|
||||
# automatic runs would keep the very toolchain this removes. Gate on the event.
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
|
||||
run: |
|
||||
bash .github/scripts/clean-machine-env.sh mask --remove
|
||||
set -a; . ./clean-machine.env; set +a
|
||||
bash .github/scripts/clean-machine-assert.sh absent
|
||||
|
||||
- name: Mount and install
|
||||
run: |
|
||||
DMG="$(ls dl/*.dmg | head -1)"
|
||||
# A real download is quarantined, and Gatekeeper treats that differently from a
|
||||
# locally built bundle: a genuine failure mode.
|
||||
xattr -w com.apple.quarantine \
|
||||
"0081;$(printf %x $(date +%s));Safari;" "$DMG" 2>/dev/null || true
|
||||
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint /Volumes/UnslothCI
|
||||
APP="$(ls -d /Volumes/UnslothCI/*.app | head -1)"
|
||||
echo "app bundle: $APP"
|
||||
cp -R "$APP" /Applications/
|
||||
hdiutil detach /Volumes/UnslothCI -quiet
|
||||
ls -la /Applications | grep -i unsloth
|
||||
|
||||
- name: Inspect the bundle (arch, signature, Gatekeeper)
|
||||
run: |
|
||||
APP="$(ls -d /Applications/*Unsloth*.app | head -1)"
|
||||
BIN="$APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist")"
|
||||
file "$BIN"
|
||||
# `lipo -archs` prints and exits 0 for a thin x86_64 binary, and `|| true`
|
||||
# swallowed even that, so architecture was never asserted. lipo is an xcrun shim,
|
||||
# gone once the strip moved CommandLineTools aside; file is base system.
|
||||
ARCHS="$(lipo -archs "$BIN" 2>/dev/null || true)"
|
||||
[ -n "$ARCHS" ] || ARCHS="$(file -b "$BIN")"
|
||||
echo "architectures: $ARCHS"
|
||||
case "$ARCHS" in
|
||||
*arm64*|*aarch64*) ;;
|
||||
*) echo "::error::the aarch64 .dmg carries no arm64 binary ($ARCHS)"; exit 1 ;;
|
||||
esac
|
||||
# Report rather than gate: an unnotarised beta is expected to fail assessment, but
|
||||
# a user WILL hit this, so it must be visible.
|
||||
codesign -dv --verbose=2 "$APP" 2>&1 | head -20 || true
|
||||
spctl -a -vvv -t install "$APP" 2>&1 | head -5 || \
|
||||
echo "::warning::Gatekeeper assessment failed -- users see 'cannot be opened' unless notarised"
|
||||
# The bundled installer is what actually failed for users, and `::error::` is only
|
||||
# an annotation that `echo` exits 0 from, so `|| echo` let a bundle with no
|
||||
# installer pass.
|
||||
if [ -f "$APP/Contents/Resources/install.sh" ]; then
|
||||
echo "bundled install.sh present"
|
||||
else
|
||||
echo "::error::no bundled install.sh in the app"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run the bundled installer, the path first launch takes
|
||||
run: |
|
||||
set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a
|
||||
set -o pipefail
|
||||
APP="$(ls -d /Applications/*Unsloth*.app | head -1)"
|
||||
# A headless runner never clicks Install: preflight sets `not_installed` and
|
||||
# returns (use-tauri-backend.ts:252-254) while startup-screen.tsx:388-389 waits
|
||||
# for the button, so launching alone sits there for 90s without ever running the
|
||||
# bundled installer. Invoke it as src-tauri/src/install.rs does: --tauri, stdin
|
||||
# closed, no tty. --tauri rejects a custom studio home (install.sh:102-114), so
|
||||
# drop the override.
|
||||
# KNOWN OUTCOME PIN, retire when the desktop release catches up to #7547. REL_TAG
|
||||
# predates #7547, so the bundle's own install.sh still hard-exits on the Xcode
|
||||
# CLT gate that #7547 replaced with a warning. Only a new release can move that,
|
||||
# not this PR. _check_macos_deps is the function #7547 added, so finding it means
|
||||
# the release caught up and this pin must go.
|
||||
SH="$APP/Contents/Resources/install.sh"
|
||||
if grep -q '_check_macos_deps' "$SH"; then
|
||||
echo "::error::the bundled install.sh now carries #7547; delete this pin block and let the venv + torch assertions below run unconditionally"
|
||||
exit 1
|
||||
fi
|
||||
rc=0
|
||||
env -u UNSLOTH_STUDIO_HOME \
|
||||
bash "$SH" --tauri \
|
||||
< /dev/null 2>&1 | tee logs/bundled-install.log || rc=$?
|
||||
echo "bundled installer exit code: $rc"
|
||||
# Exit code AND the exact gate line, so any other non-zero exit still fails.
|
||||
if [ "$rc" -eq 1 ] && grep -qE '^==> Xcode Command Line Tools are required\.[[:space:]]*$' logs/bundled-install.log; then
|
||||
echo "::notice::known pre-#7547 outcome: the shipped bundle's install.sh stopped on the Xcode CLT gate and exited 1. Not a regression here; the next desktop release retires this pin."
|
||||
exit 0
|
||||
fi
|
||||
[ "$rc" -eq 0 ] || {
|
||||
echo "::error::bundled installer exited $rc, which is neither success nor the pinned pre-#7547 outcome (exit 1 plus '==> Xcode Command Line Tools are required.')"
|
||||
exit 1
|
||||
}
|
||||
PY="$HOME/.unsloth/studio/unsloth_studio/bin/python"
|
||||
[ -x "$PY" ] || { echo "::error::bundled installer left no venv at $PY"; exit 1; }
|
||||
"$PY" -V
|
||||
# install.rs passes only --tauri, so torch is part of first launch: without this
|
||||
# the venv check passes a bundle whose only failure is the torch install.
|
||||
"$PY" -c "import torch; print('torch', torch.__version__)"
|
||||
|
||||
- name: Launch and prove it stays up
|
||||
run: |
|
||||
set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a
|
||||
APP="$(ls -d /Applications/*Unsloth*.app | head -1)"
|
||||
BIN="$APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist")"
|
||||
"$BIN" > logs/app-stdout.log 2>&1 &
|
||||
APP_PID=$!
|
||||
# 90s: long enough to clear preflight and start the bundled installer.
|
||||
for i in $(seq 1 90); do
|
||||
kill -0 "$APP_PID" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
if kill -0 "$APP_PID" 2>/dev/null; then
|
||||
echo "app still running after 90s (pid $APP_PID)"
|
||||
kill -TERM "$APP_PID" 2>/dev/null || true
|
||||
else
|
||||
wait "$APP_PID" 2>/dev/null; rc=$?
|
||||
echo "::error::desktop app exited early with rc=$rc"
|
||||
tail -50 logs/app-stdout.log || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: What did its own log say?
|
||||
if: always()
|
||||
run: |
|
||||
for f in "$UNSLOTH_STUDIO_HOME/tauri.log" "$HOME/.unsloth/studio/tauri.log"; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "=== $f ==="
|
||||
cp "$f" logs/ 2>/dev/null || true
|
||||
tail -60 "$f"
|
||||
# The two fields the bug report turned on.
|
||||
grep -E "disposition=|can_auto_repair=|Xcode Command Line|ModuleNotFoundError" "$f" || true
|
||||
found=1
|
||||
if grep -qE "desktop_preflight completed disposition=" "$f"; then disposition=1; fi
|
||||
done
|
||||
# Everything above is `|| true`, so this step could not fail while the header
|
||||
# sells the tauri.log disposition as an acceptance criterion. setup_logging
|
||||
# (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally at process start,
|
||||
# so no log means the binary never got that far, and the disposition line is the
|
||||
# field the bug report turned on: a process that hangs before preflight must not
|
||||
# pass.
|
||||
[ "${found:-0}" = "1" ] || { echo "::error::the app wrote no tauri.log; it never reached setup_logging"; exit 1; }
|
||||
[ "${disposition:-0}" = "1" ] || { echo "::error::tauri.log records no desktop_preflight disposition; the app never completed preflight"; exit 1; }
|
||||
|
||||
- name: Restore the runner
|
||||
if: always()
|
||||
# `|| true` swallowed everything, a genuinely broken restore included. The file
|
||||
# only exists once the strip step ran, and an earlier step can fail before that,
|
||||
# so skip explicitly when it is absent and let a real failure surface.
|
||||
run: |
|
||||
if [ -f .clean-machine/restore.sh ]; then
|
||||
bash .clean-machine/restore.sh
|
||||
else
|
||||
echo "no .clean-machine/restore.sh: the toolchain was never stripped, nothing to restore"
|
||||
fi
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: desktop-macos-${{ matrix.os }}
|
||||
path: logs/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
# ── Linux: .deb and .AppImage, with a real webview under Xvfb ────────────
|
||||
linux:
|
||||
# See the macOS job: a fork PR's token cannot list drafts, so skip rather than fail.
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
name: desktop linux ${{ matrix.kind }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 45
|
||||
continue-on-error: ${{ matrix.experimental }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- {kind: deb, experimental: false}
|
||||
- {kind: appimage, experimental: false}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download the shipped bundle
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p dl logs
|
||||
# See the macOS job. Loud on purpose: no bundle means nothing to prove.
|
||||
if [ -z "$REL_TAG" ]; then
|
||||
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \
|
||||
--json tagName,createdAt \
|
||||
--jq '[.[] | select(.tagName | startswith("desktop-v"))]
|
||||
| sort_by(.createdAt) | reverse | .[0].tagName // empty')"
|
||||
[ -n "$REL_TAG" ] || {
|
||||
echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)"
|
||||
exit 1
|
||||
}
|
||||
echo "resolved release tag: $REL_TAG"
|
||||
echo "REL_TAG=$REL_TAG" >> "$GITHUB_ENV"
|
||||
fi
|
||||
pat='*.deb'; [ "${{ matrix.kind }}" = "appimage" ] && pat='*.AppImage'
|
||||
gh release download "$REL_TAG" --repo "$REL_REPO" --pattern "$pat" --dir dl
|
||||
ls -la dl
|
||||
|
||||
- name: Strip the developer toolchain
|
||||
# Same gate as macOS: without it the Linux rows ignored strip_toolchain and ran the
|
||||
# bundled installer with the runner's git, gcc, cmake and make in /usr/bin.
|
||||
#
|
||||
# BEFORE the bundle install, as macOS and Windows already do: dpkg runs the
|
||||
# package's own maintainer scripts, so installing first let them see the hosted
|
||||
# image's toolchain. Nothing in that install needs a masked tool --
|
||||
# clean-machine-env.sh moves aside only $TOOLS, leaving the package manager itself
|
||||
# -- and the current bundle ships a postrm and no install-time script.
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
|
||||
run: |
|
||||
bash .github/scripts/clean-machine-env.sh mask --remove
|
||||
set -a; . ./clean-machine.env; set +a
|
||||
bash .github/scripts/clean-machine-assert.sh absent
|
||||
|
||||
- name: Install with NO dev tooling, only runtime libs
|
||||
run: |
|
||||
# Deliberately not build-essential/cmake/git: a user installing a .deb has none
|
||||
# of that. Xvfb and WebKit are runtime requirements, and apt pulls the .deb's
|
||||
# declared deps, so a wrong dependency list fails here.
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq --no-install-recommends xvfb
|
||||
if [ "${{ matrix.kind }}" = "deb" ]; then
|
||||
sudo apt-get install -y ./dl/*.deb || {
|
||||
echo "::error::the .deb does not declare its runtime dependencies correctly"
|
||||
exit 1
|
||||
}
|
||||
BIN="$(dpkg -L "$(dpkg-deb -f dl/*.deb Package)" | grep -E '/usr/bin/' | head -1)"
|
||||
else
|
||||
sudo apt-get install -y -qq --no-install-recommends libfuse2 \
|
||||
libwebkit2gtk-4.1-0 libgtk-3-0 libayatana-appindicator3-1 || true
|
||||
chmod +x dl/*.AppImage
|
||||
BIN="$(ls dl/*.AppImage | head -1)"
|
||||
fi
|
||||
echo "BIN=$BIN" >> "$GITHUB_ENV"
|
||||
echo "binary: $BIN"
|
||||
|
||||
# The strip runs before this, but `apt-get install ./dl/*.deb` then pulls the bundle's
|
||||
# DECLARED dependencies, so a release that adds git, cmake or a compiler to that list
|
||||
# puts one back in /usr/bin and both required Linux rows still pass. `absent` ran only
|
||||
# beforehand, so re-run it here, before the bundled installer. The current dependency
|
||||
# closure is 65 packages of runtime libs and no toolchain, so this is green today and
|
||||
# only a new dependency can turn it red.
|
||||
- name: Re-assert the toolchain is still absent after the package install
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
|
||||
run: |
|
||||
set -a; . ./clean-machine.env; set +a
|
||||
bash .github/scripts/clean-machine-assert.sh absent
|
||||
|
||||
- name: Run the bundled installer, the path first launch takes
|
||||
run: |
|
||||
set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a
|
||||
set -o pipefail
|
||||
# The launch step below only proves the process stayed alive: on a fresh home
|
||||
# preflight reports not_installed and the app waits on the install screen for a
|
||||
# click (use-tauri-backend.ts:252-254, startup-screen.tsx:388-389), so a bundle
|
||||
# whose embedded install.sh was missing or broken passed both Linux rows.
|
||||
# tauri.conf.json:56-59 ships it as a bundle resource, so find it there and run
|
||||
# it as install.rs does.
|
||||
if [ "${{ matrix.kind }}" = "deb" ]; then
|
||||
SH="$(dpkg -L "$(dpkg-deb -f dl/*.deb Package)" | grep -E '/install\.sh$' | head -1)"
|
||||
else
|
||||
# ls returns a bare filename here, and a command word with no slash resolves
|
||||
# through PATH, not the cwd, so this needs the ./ prefix.
|
||||
(cd dl && "./$(ls *.AppImage | head -1)" --appimage-extract >/dev/null)
|
||||
SH="$(find dl/squashfs-root -name install.sh -type f | head -1)"
|
||||
fi
|
||||
[ -n "$SH" ] && [ -f "$SH" ] || { echo "::error::the bundle ships no install.sh resource"; exit 1; }
|
||||
echo "bundled installer: $SH"
|
||||
# KNOWN OUTCOME PIN, retire when the desktop release catches up to #7547. The
|
||||
# bundle carries its own install.sh and REL_TAG predates #7547, so on a stripped
|
||||
# runner it still exits 2 at the NEED_SUDO handshake for the optional set instead
|
||||
# of falling through to prebuilt llama.cpp. Only a new release can move that, not
|
||||
# this PR. _SMART_APT_OPTIONAL is the guard #7547 added, so finding it means the
|
||||
# release caught up and this pin must go.
|
||||
if grep -q '_SMART_APT_OPTIONAL' "$SH"; then
|
||||
echo "::error::the bundled install.sh now carries #7547; delete this pin block and let the venv + torch assertions below run unconditionally"
|
||||
exit 1
|
||||
fi
|
||||
# --tauri rejects a custom studio home (install.sh:102-114), so drop the
|
||||
# workspace-scoped override; close stdin as install.rs does.
|
||||
rc=0
|
||||
env -u UNSLOTH_STUDIO_HOME \
|
||||
bash "$SH" --tauri < /dev/null 2>&1 | tee logs/bundled-install.log || rc=$?
|
||||
echo "bundled installer exit code: $rc"
|
||||
# Exit code AND the exact optional set, so a different NEED_SUDO list or any other
|
||||
# non-zero exit is still a failure.
|
||||
if [ "$rc" -eq 2 ] && grep -qE '^\[TAURI:NEED_SUDO\] cmake git build-essential libcurl4-openssl-dev[[:space:]]*$' logs/bundled-install.log; then
|
||||
echo "::notice::known pre-#7547 outcome: the shipped bundle's install.sh asked to elevate for the optional set and exited 2. Not a regression here; the next desktop release retires this pin."
|
||||
exit 0
|
||||
fi
|
||||
[ "$rc" -eq 0 ] || {
|
||||
echo "::error::bundled installer exited $rc, which is neither success nor the pinned pre-#7547 outcome (exit 2 plus exactly '[TAURI:NEED_SUDO] cmake git build-essential libcurl4-openssl-dev')"
|
||||
exit 1
|
||||
}
|
||||
PY="$HOME/.unsloth/studio/unsloth_studio/bin/python"
|
||||
[ -x "$PY" ] || { echo "::error::bundled installer left no venv at $PY"; exit 1; }
|
||||
"$PY" -V
|
||||
# install.rs passes only --tauri, so torch is part of first launch.
|
||||
"$PY" -c "import torch; print('torch', torch.__version__)"
|
||||
|
||||
- name: Launch under Xvfb and prove it stays up
|
||||
run: |
|
||||
set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a
|
||||
# Linux is the one platform where a hosted runner can give the app a real display,
|
||||
# so this is the strongest "does the UI come up" check available without
|
||||
# self-hosted hardware.
|
||||
xvfb-run -a --server-args="-screen 0 1440x900x24" \
|
||||
"$BIN" > logs/app-stdout.log 2>&1 &
|
||||
APP_PID=$!
|
||||
for i in $(seq 1 90); do
|
||||
kill -0 "$APP_PID" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
if kill -0 "$APP_PID" 2>/dev/null; then
|
||||
echo "app still running after 90s"
|
||||
kill -TERM "$APP_PID" 2>/dev/null || true
|
||||
else
|
||||
wait "$APP_PID" 2>/dev/null; rc=$?
|
||||
echo "::error::desktop app exited early with rc=$rc"
|
||||
tail -60 logs/app-stdout.log || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: What did its own log say?
|
||||
if: always()
|
||||
run: |
|
||||
for f in "$UNSLOTH_STUDIO_HOME/tauri.log" "$HOME/.unsloth/studio/tauri.log"; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "=== $f ==="; cp "$f" logs/ 2>/dev/null || true; tail -60 "$f"
|
||||
grep -E "disposition=|can_auto_repair=|ModuleNotFoundError" "$f" || true
|
||||
found=1
|
||||
if grep -qE "desktop_preflight completed disposition=" "$f"; then disposition=1; fi
|
||||
done
|
||||
# Same acceptance criterion the macOS rows enforce, and for the same reason:
|
||||
# everything above is `|| true` and the loop skips a missing log, so without
|
||||
# these two lines the step could not fail.
|
||||
[ "${found:-0}" = "1" ] || { echo "::error::the app wrote no tauri.log; it never reached setup_logging"; exit 1; }
|
||||
[ "${disposition:-0}" = "1" ] || { echo "::error::tauri.log records no desktop_preflight disposition; the app never completed preflight"; exit 1; }
|
||||
|
||||
- name: Restore the runner
|
||||
if: always()
|
||||
# See the macOS job: `|| true` would swallow a genuinely broken restore.
|
||||
run: |
|
||||
if [ -f .clean-machine/restore.sh ]; then
|
||||
bash .clean-machine/restore.sh
|
||||
else
|
||||
echo "no .clean-machine/restore.sh: the toolchain was never stripped, nothing to restore"
|
||||
fi
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: desktop-linux-${{ matrix.kind }}
|
||||
path: logs/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
# ── Windows: NSIS setup.exe, silent install ──────────────────────────────
|
||||
windows:
|
||||
# See the macOS job: a fork PR's token cannot list drafts, so skip rather than fail.
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
name: desktop windows
|
||||
runs-on: windows-latest
|
||||
# 60, not 45: this job runs the bundled installer, and a full torch install on a
|
||||
# Windows runner is the slowest of the three platforms.
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download the shipped installer
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p dl logs
|
||||
# See the macOS job. Loud on purpose: no bundle means nothing to prove.
|
||||
if [ -z "$REL_TAG" ]; then
|
||||
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \
|
||||
--json tagName,createdAt \
|
||||
--jq '[.[] | select(.tagName | startswith("desktop-v"))]
|
||||
| sort_by(.createdAt) | reverse | .[0].tagName // empty')"
|
||||
[ -n "$REL_TAG" ] || {
|
||||
echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)"
|
||||
exit 1
|
||||
}
|
||||
echo "resolved release tag: $REL_TAG"
|
||||
echo "REL_TAG=$REL_TAG" >> "$GITHUB_ENV"
|
||||
fi
|
||||
gh release download "$REL_TAG" --repo "$REL_REPO" --pattern '*setup.exe' --dir dl
|
||||
ls -la dl
|
||||
|
||||
- name: Strip the developer toolchain
|
||||
# Same gate as macOS, for the same `inputs`-coercion reason.
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\', 'CMake',
|
||||
'Microsoft Visual Studio', 'BuildTools', 'LLVM', 'MSYS', 'mingw')
|
||||
# winget is an app-execution alias under ...\Local\Microsoft\WindowsApps, so the
|
||||
# WindowsApps fragment -- there to take the Store's python.exe alias away --
|
||||
# drops the OS package manager with it. winget is not developer tooling: every
|
||||
# consumer Windows machine this bundle ships to has it, and the bundled
|
||||
# install.ps1 reaches for it for the git that studio/setup.ps1:1657-1669 still
|
||||
# gates on unconditionally. Without it this lane only re-runs, as a hard failure,
|
||||
# the no-winget fallback clean-machine-install-ci.yml already covers and pins on
|
||||
# its winget=masked row. Resolve winget before the scrub and hand it back through
|
||||
# a shim, exactly as that workflow does.
|
||||
$wingetCmd = Get-Command winget -ErrorAction SilentlyContinue
|
||||
if (-not $wingetCmd) {
|
||||
Write-Host '::error::winget was not on PATH before the strip; this image ships it and the bundled installer needs it'
|
||||
exit 1
|
||||
}
|
||||
$shim = Join-Path $env:RUNNER_TEMP 'winget-shim'
|
||||
New-Item -ItemType Directory -Force -Path $shim | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $shim 'winget.cmd') -Encoding ascii `
|
||||
-Value "@`"$($wingetCmd.Source)`" %*"
|
||||
$scrub = {
|
||||
param($entries)
|
||||
,@($entries | Where-Object { $p = $_; $p -and -not ($drop | Where-Object { $p -like "*$_*" }) })
|
||||
}
|
||||
"PATH=$shim;$((& $scrub ($env:PATH -split ';')) -join ';')" |
|
||||
Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# Off disk, not just off PATH: py.exe lives in C:\Windows (which must stay) and
|
||||
# uv does its own discovery, so both reach the toolcache whatever PATH says.
|
||||
foreach ($tc in @("$env:AGENT_TOOLSDIRECTORY\Python", 'C:\hostedtoolcache\windows\Python')) {
|
||||
if ($tc -and (Test-Path $tc)) {
|
||||
try { Rename-Item -LiteralPath $tc -NewName 'Python.masked' -ErrorAction Stop
|
||||
Write-Host "masked toolcache python: $tc" }
|
||||
catch { Write-Host "::error::could not mask $tc ($($_.Exception.Message)); the job would not be clean"; exit 1 }
|
||||
}
|
||||
}
|
||||
# The bundled install.ps1 this job runs calls Refresh-SessionPath (318-337), which
|
||||
# merges the Machine and User registry PATHs back into $env:Path, so a
|
||||
# process-only scrub lasts until the first refresh and Git/CMake/VS/LLVM come back
|
||||
# from the registry. The runner is ephemeral, so rewrite the registry copies too.
|
||||
# (A merge keeps what the process already had, which is why the winget shim above
|
||||
# survives.) Expand first: SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ
|
||||
# (dotnet/runtime#1442).
|
||||
foreach ($scope in 'Machine','User') {
|
||||
$raw = [System.Environment]::GetEnvironmentVariable('Path', $scope)
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) { continue }
|
||||
$expanded = [System.Environment]::ExpandEnvironmentVariables($raw) -split ';'
|
||||
try {
|
||||
[System.Environment]::SetEnvironmentVariable('Path', ((& $scrub $expanded) -join ';'), $scope)
|
||||
} catch {
|
||||
Write-Host "::error::could not scrub the $scope PATH ($($_.Exception.Message)); the strip would not survive Refresh-SessionPath"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
foreach ($v in 'VSINSTALLDIR','VCINSTALLDIR','WindowsSdkDir','INCLUDE','LIB','LIBPATH') {
|
||||
"$v=" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
}
|
||||
exit 0
|
||||
|
||||
- name: Verify the strip took effect
|
||||
# PATH written to $GITHUB_ENV only applies to LATER steps, so the scrub can only be
|
||||
# checked from here. The drop list above is heuristic path-fragment matching: if a
|
||||
# runner image moves any of these tools outside those fragments, the bundled
|
||||
# install.ps1 reuses the survivor and this job still calls itself clean. Same
|
||||
# assertion the installer workflow runs, same reason.
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$leaked = @()
|
||||
foreach ($t in 'python','py','git','cmake','cl') {
|
||||
$f = Get-Command $t -ErrorAction SilentlyContinue
|
||||
Write-Host ("{0,-8} {1}" -f $t, $(if ($f) { $f.Source } else { 'ABSENT' }))
|
||||
if ($f -and $t -ne 'py') { $leaked += "$t -> $($f.Source)" }
|
||||
}
|
||||
# `py` itself lives in C:\Windows and stays. Only an interpreter it can still
|
||||
# START is a leak, because Find-CompatiblePython (install.ps1:1130-1153) probes
|
||||
# `py` first. `py -0p` is only the launcher's REGISTRY view, which still names the
|
||||
# paths the rename removed, so a start attempt is the only real evidence.
|
||||
if (Get-Command py -ErrorAction SilentlyContinue) {
|
||||
foreach ($v in '-3.11', '-3.12', '-3.13') {
|
||||
$out = & py $v -c "import sys; print(sys.executable)" 2>&1
|
||||
$rc = $LASTEXITCODE
|
||||
Write-Host ("py {0} -> exit {1}: {2}" -f $v, $rc, (($out | Out-String).Trim() -replace '\r?\n', ' / '))
|
||||
if ($rc -eq 0) { $leaked += "py $v -> $out" }
|
||||
}
|
||||
# A failing probe is the outcome we want, but it leaves $LASTEXITCODE non-zero
|
||||
# and the runner appends `exit $LASTEXITCODE` to every pwsh step
|
||||
# (actions/runner#351), so the step would exit 1 on a machine that is clean.
|
||||
$global:LASTEXITCODE = 0
|
||||
}
|
||||
# The shim is the only reason winget resolves after the WindowsApps drop. It
|
||||
# survives the installer's own refreshes because Refresh-SessionPath
|
||||
# (install.ps1:318-337) and setup.ps1's Refresh-Environment MERGE the current
|
||||
# $env:Path back in rather than replace it -- but assert that, or this lane
|
||||
# silently degrades into the no-winget leg the installer workflow already pins.
|
||||
$winget = Get-Command winget -ErrorAction SilentlyContinue
|
||||
Write-Host ("winget {0}" -f $(if ($winget) { $winget.Source } else { 'ABSENT' }))
|
||||
if (-not $winget) {
|
||||
Write-Host '::error::winget did not survive the strip; the bundled installer would take the no-winget fallback instead of the consumer path'
|
||||
exit 1
|
||||
}
|
||||
if ($leaked) {
|
||||
Write-Host "::error::developer tooling survived the strip: $($leaked -join '; ')"
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
|
||||
- name: Silent install
|
||||
shell: pwsh
|
||||
run: |
|
||||
$exe = (Get-ChildItem dl/*setup.exe | Select-Object -First 1).FullName
|
||||
# /S is the NSIS silent switch: a user double-clicks, but an installer that cannot
|
||||
# run unattended cannot be scripted or MDM-deployed either.
|
||||
$p = Start-Process -FilePath $exe -ArgumentList '/S' -Wait -PassThru
|
||||
Write-Host "installer exit: $($p.ExitCode)"
|
||||
if ($p.ExitCode -ne 0) { Write-Host "::error::silent install failed"; exit 1 }
|
||||
$found = Get-ChildItem -Path "$env:LOCALAPPDATA","$env:ProgramFiles" -Recurse `
|
||||
-Filter '*Unsloth*.exe' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if (-not $found) { Write-Host '::error::no installed executable found'; exit 1 }
|
||||
Write-Host "installed: $($found.FullName)"
|
||||
"APP_EXE=$($found.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Run the bundled installer, the path first launch takes
|
||||
shell: pwsh
|
||||
run: |
|
||||
# The launch step below only proves the process stayed alive: on a fresh profile
|
||||
# the app waits for a click on Install (use-tauri-backend.ts:252-254,
|
||||
# startup-screen.tsx:388-389), so this job passed on a bundle whose embedded
|
||||
# install.ps1 was missing or broken. tauri.conf.json:56-59 ships it as a bundle
|
||||
# resource, so find it where NSIS put it and invoke it as install.rs:326-341.
|
||||
$root = Split-Path -Parent $env:APP_EXE
|
||||
$ps1 = Get-ChildItem -Path $root -Recurse -Filter 'install.ps1' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if (-not $ps1) {
|
||||
Write-Host '::error::the bundle ships no install.ps1 resource'
|
||||
exit 1
|
||||
}
|
||||
Write-Host "bundled installer: $($ps1.FullName)"
|
||||
# --tauri rejects a custom studio home (install.ps1:189-215), so drop the
|
||||
# override as install.rs does (354-357).
|
||||
Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue
|
||||
& powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass `
|
||||
-File $ps1.FullName --tauri *>&1 | Tee-Object -FilePath logs/bundled-install.log
|
||||
$rc = $LASTEXITCODE
|
||||
Write-Host "bundled installer exit: $rc"
|
||||
if ($rc -ne 0) {
|
||||
Write-Host "::error::bundled installer exited $rc"
|
||||
exit $rc
|
||||
}
|
||||
# The exit code alone is not enough: it is the venv the app then boots from.
|
||||
$py = Join-Path $env:USERPROFILE '.unsloth\studio\unsloth_studio\Scripts\python.exe'
|
||||
if (-not (Test-Path $py)) {
|
||||
Write-Host "::error::bundled installer left no venv at $py"
|
||||
exit 1
|
||||
}
|
||||
& $py -V
|
||||
# install.rs passes only --tauri, so torch is part of first launch, and a venv that
|
||||
# cannot import it is the unbootable environment from the report.
|
||||
& $py -c "import torch; print('torch', torch.__version__)"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host '::error::the bundled install produced a venv with no working torch'
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Launch and prove it stays up
|
||||
shell: pwsh
|
||||
run: |
|
||||
$p = Start-Process -FilePath $env:APP_EXE -PassThru `
|
||||
-RedirectStandardOutput logs/app-stdout.log `
|
||||
-RedirectStandardError logs/app-stderr.log
|
||||
for ($i = 0; $i -lt 90; $i++) { if ($p.HasExited) { break }; Start-Sleep -Seconds 1 }
|
||||
if ($p.HasExited) {
|
||||
Write-Host "::error::desktop app exited early with rc=$($p.ExitCode)"
|
||||
Get-Content logs/app-stdout.log, logs/app-stderr.log -Tail 40 -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
Write-Host "app still running after 90s"
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
|
||||
- name: What did its own log say?
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$found = $false; $disposition = $false
|
||||
foreach ($f in @("$env:UNSLOTH_STUDIO_HOME\tauri.log",
|
||||
"$env:USERPROFILE\.unsloth\studio\tauri.log")) {
|
||||
if (Test-Path $f) {
|
||||
Write-Host "=== $f ==="
|
||||
Copy-Item $f logs/ -ErrorAction SilentlyContinue
|
||||
Get-Content $f -Tail 60
|
||||
Select-String -Path $f -Pattern 'disposition=|can_auto_repair=|ModuleNotFoundError' `
|
||||
-ErrorAction SilentlyContinue
|
||||
$found = $true
|
||||
if (Select-String -Path $f -Pattern 'desktop_preflight completed disposition=' `
|
||||
-SimpleMatch -Quiet) { $disposition = $true }
|
||||
}
|
||||
}
|
||||
# Same acceptance criterion macOS and Linux enforce: Test-Path, Get-Content and
|
||||
# Select-String cannot fail, so without these two lines the step was decoration.
|
||||
if (-not $found) {
|
||||
Write-Host '::error::the app wrote no tauri.log; it never reached setup_logging'
|
||||
exit 1
|
||||
}
|
||||
if (-not $disposition) {
|
||||
Write-Host '::error::tauri.log records no desktop_preflight disposition; the app never completed preflight'
|
||||
exit 1
|
||||
}
|
||||
exit 0
|
||||
|
||||
- name: Upload logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: desktop-windows
|
||||
path: logs/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
31
install.ps1
31
install.ps1
|
|
@ -2761,6 +2761,37 @@ exit 0
|
|||
}
|
||||
}
|
||||
|
||||
# ── CI only: overlay a source checkout over the package just installed ──
|
||||
# Mirrors install.sh. Not a consumer knob: no switch, absent from the usage
|
||||
# text, ignored unless UNSLOTH_CI_SOURCE_OVERLAY names a directory holding a
|
||||
# pyproject.toml.
|
||||
#
|
||||
# The clean-machine legs run THIS script from a branch but install unsloth
|
||||
# from PyPI, the consumer path, so everything Python-side (studio/setup.ps1,
|
||||
# install_python_stack.py and every requirements/constraints file they reach
|
||||
# via Path(__file__)) would be the released wheel's and a branch could not be
|
||||
# validated. `& $UnslothExe studio setup` below goes through the CLI, and an
|
||||
# editable overlay makes _PACKAGE_ROOT in unsloth_cli/commands/studio.py
|
||||
# resolve to the working tree by PEP 660 __file__, so setup.ps1 comes from
|
||||
# this ref. NOT --local: that also installs `unsloth-zoo @
|
||||
# git+https://github.com/unslothai/unsloth-zoo`, which genuinely needs git,
|
||||
# and git absence is what the masked leg proves; editable + --no-deps
|
||||
# resolves and clones nothing, so it survives git, cmake and MSVC all missing.
|
||||
if ($env:UNSLOTH_CI_SOURCE_OVERLAY) {
|
||||
$CiOverlayRoot = $env:UNSLOTH_CI_SOURCE_OVERLAY
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $CiOverlayRoot "pyproject.toml"))) {
|
||||
Write-Host "[ERROR] UNSLOTH_CI_SOURCE_OVERLAY is set to '$CiOverlayRoot' but there is no pyproject.toml there." -ForegroundColor Red
|
||||
return (Exit-InstallFailure "UNSLOTH_CI_SOURCE_OVERLAY has no pyproject.toml: $CiOverlayRoot")
|
||||
}
|
||||
substep "CI: overlaying source checkout (editable, no deps): $CiOverlayRoot"
|
||||
# Retry: the editable build fetches its build backend from PyPI, same
|
||||
# transient-network risk as every other step.
|
||||
$CiOverlayExit = Invoke-InstallCommandRetry -Label "overlay CI source checkout" -Command { uv pip install --python $VenvPython --no-deps -e $CiOverlayRoot }
|
||||
if ($CiOverlayExit -ne 0) {
|
||||
return (Exit-InstallFailure "Failed to overlay the CI source checkout (exit code $CiOverlayExit)" $CiOverlayExit)
|
||||
}
|
||||
}
|
||||
|
||||
# ── Run studio setup ──
|
||||
# setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools,
|
||||
# CUDA Toolkit, and other dependencies automatically via winget. Node.js is
|
||||
|
|
|
|||
26
install.sh
26
install.sh
|
|
@ -4185,6 +4185,32 @@ if [ "$SKIP_TORCH" = false ] && [ -n "${TORCH_INDEX_URL:-}" ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
# ── CI only: overlay a source checkout over the package just installed ──
|
||||
# Not a consumer knob: no flag, absent from --help, ignored unless
|
||||
# UNSLOTH_CI_SOURCE_OVERLAY names a directory holding a pyproject.toml.
|
||||
#
|
||||
# The clean-machine legs run THIS script from a branch but install unsloth from
|
||||
# PyPI, the consumer path, so everything Python-side (studio/setup.sh, setup.ps1,
|
||||
# install_python_stack.py and every requirements/constraints file they reach via
|
||||
# Path(__file__)) would be the released wheel's and a branch could not be
|
||||
# validated. An editable overlay re-points `import studio` at the working tree, so
|
||||
# the importlib.resources lookup below finds this ref's setup.sh. NOT --local:
|
||||
# that also installs `unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo`,
|
||||
# which genuinely needs git, and git absence is what these legs prove; editable +
|
||||
# --no-deps resolves and clones nothing, so it survives git, cmake and the C/C++
|
||||
# compilers all being gone.
|
||||
if [ -n "${UNSLOTH_CI_SOURCE_OVERLAY:-}" ]; then
|
||||
if [ ! -f "$UNSLOTH_CI_SOURCE_OVERLAY/pyproject.toml" ]; then
|
||||
echo "[ERROR] UNSLOTH_CI_SOURCE_OVERLAY is set to '$UNSLOTH_CI_SOURCE_OVERLAY' but there is no pyproject.toml there." >&2
|
||||
exit 1
|
||||
fi
|
||||
substep "CI: overlaying source checkout (editable, no deps): $UNSLOTH_CI_SOURCE_OVERLAY"
|
||||
# Retry: the editable build fetches its build backend from PyPI, same
|
||||
# transient-network risk as every other install step.
|
||||
run_install_cmd_retry "overlay CI source checkout" uv pip install --python "$_VENV_PY" \
|
||||
--no-deps -e "$UNSLOTH_CI_SOURCE_OVERLAY"
|
||||
fi
|
||||
|
||||
# ── Run studio setup ──
|
||||
tauri_log "STEP" "Running Unsloth setup"
|
||||
# When --local, use the repo's own setup.sh directly.
|
||||
|
|
|
|||
|
|
@ -2790,6 +2790,9 @@ def pip_install_try(
|
|||
env = _install_env_for_cmd(cmd),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# As pip_install below: `nobuild` only catches a build that reaches the log.
|
||||
if VERBOSE and result.stdout:
|
||||
print(_redact_install_output(result.stdout))
|
||||
return True
|
||||
if VERBOSE and result.stdout:
|
||||
# pip/uv echo index URLs (credentials included) in failure output.
|
||||
|
|
@ -2845,6 +2848,15 @@ def pip_install(
|
|||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Echo success under UNSLOTH_VERBOSE, as install.sh's
|
||||
# run_install_cmd does. Without it the dependency phase never
|
||||
# reached the install log that clean-machine-assert.sh's `nobuild`
|
||||
# greps for uv's "Building <pkg>==<ver>", so a source build in this
|
||||
# step -- the studio.txt install, where sdist-only dependencies
|
||||
# actually show up -- reported "built: none" and stayed green.
|
||||
# Redacted: uv echoes index URLs with credentials.
|
||||
if VERBOSE and result.stdout:
|
||||
print(_redact_install_output(result.stdout))
|
||||
return
|
||||
print(_red(f" uv failed, falling back to pip..."))
|
||||
if result.stdout:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue