Tighten the clean-machine comments
Compress the comment blocks across the clean-machine workflows and scripts. The explanations of why each check is written the way it is stay; the padding, restatement and duplication go. No code or workflow logic changes.
This commit is contained in:
parent
90ec9462a0
commit
231fcc3cf0
4 changed files with 224 additions and 253 deletions
68
.github/scripts/clean-machine-assert.sh
vendored
68
.github/scripts/clean-machine-assert.sh
vendored
|
|
@ -4,15 +4,14 @@
|
|||
#
|
||||
# Assert the clean-machine contract after an install attempt.
|
||||
#
|
||||
# absent The toolchain really was absent for the whole run. Guards against a
|
||||
# leg that "passed" only because masking silently failed, or because
|
||||
# the installer quietly installed Xcode CLT behind our back.
|
||||
# absent The toolchain really was absent for the whole run. Guards against a leg
|
||||
# that "passed" only because masking silently failed, or because the
|
||||
# installer quietly installed Xcode CLT behind our back.
|
||||
# notools The trace recorded no compiler/git/brew invocation (trace mode).
|
||||
# nobuild The install log shows no source build (no sdist, no cmake, no
|
||||
# "Building wheel" from pip and no "Building <pkg>==<ver>" from uv).
|
||||
# This is the wheels-only contract. It needs UNSLOTH_VERBOSE=1 on the
|
||||
# installer, otherwise run_install_cmd (install.sh:193-243) throws the
|
||||
# uv output away on success and there is nothing here to read.
|
||||
# nobuild The wheels-only contract: no "Building wheel" from pip, no
|
||||
# "Building <pkg>==<ver>" from uv. Needs UNSLOTH_VERBOSE=1, else
|
||||
# run_install_cmd (install.sh:193-243) discards the uv output on success
|
||||
# and there is nothing here to read.
|
||||
#
|
||||
# Usage: bash .github/scripts/clean-machine-assert.sh absent notools nobuild
|
||||
set -uo pipefail
|
||||
|
|
@ -28,11 +27,9 @@ for check in "$@"; do
|
|||
case "$check" in
|
||||
|
||||
absent)
|
||||
# Deliberately NOT a `command -v` check. On a real virgin Mac /usr/bin/git and
|
||||
# /usr/bin/cc EXIST as Xcode CLT stubs, so `command -v git` SUCCEEDS -- running
|
||||
# it is what fails ("xcrun: error: invalid active developer path"). Asserting on
|
||||
# `command -v` would therefore be unfaithful and would fail on a correctly masked
|
||||
# runner. The honest invariant is: the tool must not WORK.
|
||||
# Deliberately NOT `command -v`: on a virgin Mac /usr/bin/{git,cc} EXIST as CLT
|
||||
# stubs, so `command -v` succeeds and only RUNNING them fails ("xcrun: error:
|
||||
# invalid active developer path"). The honest invariant is: must not WORK.
|
||||
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
|
||||
|
|
@ -41,10 +38,10 @@ for check in "$@"; do
|
|||
for tool in git cc clang cmake; do
|
||||
command -v "$tool" >/dev/null 2>&1 || { ok "$tool not on PATH"; continue; }
|
||||
if "$tool" --version >/dev/null 2>&1; then
|
||||
# On Intel runners /usr/bin/git keeps working once the CLT are gone, so it
|
||||
# is not CLT-provided there and no masking can remove it. cc and clang do
|
||||
# become stubs, and the consumer path needs no git on macOS, so report it
|
||||
# rather than calling the simulation broken.
|
||||
# On Intel runners /usr/bin/git is not CLT-provided and keeps working once
|
||||
# the CLT are gone, so no masking can remove it. cc and clang do become
|
||||
# stubs and the macOS consumer path needs no git, so report rather than
|
||||
# call the simulation broken.
|
||||
case " ${UNSLOTH_CLEAN_ALLOW_WORKING:-} " in
|
||||
*" $tool "*)
|
||||
echo "[assert] NOTE $tool still works ($(command -v "$tool")); allowed on this runner"
|
||||
|
|
@ -68,19 +65,18 @@ for check in "$@"; do
|
|||
if [ -z "$TRACE" ] || [ ! -f "$TRACE" ]; then
|
||||
fail "notools requested but no trace file (\$UNSLOTH_TOOL_TRACE=$TRACE)"
|
||||
else
|
||||
# git is legitimate under --local (it installs unsloth-zoo from a git URL);
|
||||
# UNSLOTH_ALLOW_TOOLS lets that leg allow-list it explicitly.
|
||||
# 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` ASKS whether a toolchain is selected; it cannot build
|
||||
# anything. The installer has to ask in order to tell the user whether a
|
||||
# source build is available, and the whole point of the fix is that it then
|
||||
# carries on without one. Treating the question as toolchain USE would fail
|
||||
# the very leg that proves the toolchain was never used. `--install`, which
|
||||
# pops the CLT installer, stays a hit.
|
||||
# `xcode-select -p` only ASKS whether a toolchain is selected; the installer
|
||||
# has to ask, and the point of the fix is that it carries on without one.
|
||||
# Counting the question as toolchain USE would fail the very leg that proves
|
||||
# the toolchain was never used. `--install`, which pops the CLT installer,
|
||||
# stays a hit.
|
||||
if [ "$tool" = "xcode-select" ]; then
|
||||
case "$rest" in
|
||||
-p|--print-path|-v|--version|"") continue ;;
|
||||
|
|
@ -98,25 +94,21 @@ for check in "$@"; do
|
|||
;;
|
||||
|
||||
nobuild)
|
||||
# "Built an sdist" is NOT the same as "needed a compiler". Four packages on the
|
||||
# macOS path are sdist-only PURE PYTHON projects that build fine with no
|
||||
# toolchain (verified by resolving each against cp313/macos-arm64):
|
||||
# "Built an sdist" is NOT "needed a compiler". Four packages on the macOS path
|
||||
# are sdist-only PURE PYTHON (verified against cp313/macos-arm64):
|
||||
# openai-whisper, argbind, randomname -- no version ever ships a wheel
|
||||
# antlr4-python3-runtime==4.9.3 -- pinned below the 4.13.2 wheel
|
||||
# Failing on those would be a false alarm, so the contract asserted here is
|
||||
# "nothing that needs a COMPILER was built", with that allowlist subtracted.
|
||||
# UNSLOTH_ALLOW_SDIST can extend it.
|
||||
# Failing on those is a false alarm, so the contract is "nothing needing a
|
||||
# COMPILER was built". UNSLOTH_ALLOW_SDIST extends the allowlist.
|
||||
_allow="openai-whisper argbind randomname antlr4-python3-runtime ${UNSLOTH_ALLOW_SDIST:-}"
|
||||
if [ ! -f "$LOG" ]; then
|
||||
fail "nobuild requested but $LOG is missing"
|
||||
else
|
||||
# The installer runs `uv pip install`, and uv does NOT use pip's phrasing.
|
||||
# It prints ` Building <name>==<version>` and ` Built <name>==<version>`
|
||||
# to stderr, as plain lines once stderr is not a TTY (astral-sh/uv#11165), so
|
||||
# the pip-only pattern left _built empty on every uv source build. Match both
|
||||
# spellings. Requiring `==` or ` @ ` after the name keeps this off the
|
||||
# installer's own lowercase "building frontend..." progress text. Strip ANSI
|
||||
# first so a coloured run (FORCE_COLOR) still parses.
|
||||
# uv does NOT use pip's phrasing: it prints `Building <name>==<version>` to
|
||||
# stderr (astral-sh/uv#11165), so the pip-only pattern left _built empty on
|
||||
# every uv source build. Match both spellings. Requiring `==` or ` @ ` after
|
||||
# the name keeps this off the installer's own lowercase "building frontend..."
|
||||
# progress text. Strip ANSI first so a coloured run (FORCE_COLOR) parses.
|
||||
_esc=$(printf '\033')
|
||||
_built="$(sed -E "s/${_esc}\[[0-9;]*[A-Za-z]//g" "$LOG" 2>/dev/null \
|
||||
| grep -oiE "building wheel for [a-z0-9._-]+|building [a-z0-9._-]+(==| @ )" \
|
||||
|
|
|
|||
68
.github/scripts/clean-machine-env.sh
vendored
68
.github/scripts/clean-machine-env.sh
vendored
|
|
@ -2,25 +2,20 @@
|
|||
# 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, so the installer
|
||||
# is exercised the way a real user's brand-new Mac / PC exercises it.
|
||||
# Simulate a virgin developer machine on a GitHub-hosted runner. Two modes, because
|
||||
# "the tool is absent" and "the installer never called the tool" cannot be simulated
|
||||
# by the same mechanism:
|
||||
#
|
||||
# Two modes, because "the tool is absent" and "the installer never called the tool"
|
||||
# CANNOT be simulated by the same mechanism:
|
||||
# mask Make the toolchain genuinely ABSENT: scrub PATH to OS defaults and (with
|
||||
# --remove) move the real toolchain aside, so `command -v git` correctly
|
||||
# FAILS, as on a clean Mac. A failing "poison shim" would do the opposite --
|
||||
# `command -v` finds it and reports the tool as present -- so no shims here.
|
||||
# trace Leave the toolchain working but route it through logging wrappers that log
|
||||
# the call then exec the real binary, proving whether the installer ever
|
||||
# REACHES for a compiler/git without changing behaviour.
|
||||
#
|
||||
# mask Make the toolchain genuinely ABSENT. Scrubs PATH down to the OS
|
||||
# defaults and (with --remove) moves the real toolchain aside. After
|
||||
# this, `command -v git` correctly FAILS, which is what a clean Mac does.
|
||||
# A failing "poison shim" on PATH would do the opposite -- `command -v`
|
||||
# finds it and reports the tool as present -- so shims are NOT used here.
|
||||
#
|
||||
# trace Leave the toolchain working, but route it through logging wrappers that
|
||||
# record the invocation and then exec the real binary. Proves whether the
|
||||
# installer ever REACHES for a compiler/git, without changing behaviour.
|
||||
#
|
||||
# Writes shell exports to $CLEAN_ENV_FILE (default ./clean-machine.env) for the
|
||||
# caller to `source`. Nothing is exported globally, so other workflow steps
|
||||
# (checkout, upload-artifact) keep a normal environment.
|
||||
# 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]
|
||||
|
|
@ -55,8 +50,8 @@ TOOLS="xcode-select xcrun clang clang++ cc c++ gcc g++ git cmake make brew ninja
|
|||
note() { echo "[clean-machine] $*"; }
|
||||
|
||||
# ── PATH scrub ────────────────────────────────────────────────────────────────
|
||||
# Keep only OS-default system dirs. Drops Homebrew, the hosted Python toolcache,
|
||||
# setup-* shims, pipx, cargo, and every other preinstalled developer dir.
|
||||
# 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
|
||||
|
|
@ -76,11 +71,9 @@ if [ "$MODE" = "mask" ]; then
|
|||
NEWPATH="$(scrub_path)"
|
||||
{
|
||||
echo "export PATH='$NEWPATH'"
|
||||
# DEVELOPER_DIR must be UNSET, not pointed at a fake path: `xcode-select -p`
|
||||
# honours DEVELOPER_DIR and prints it verbatim with exit 0, so setting it to a
|
||||
# nonexistent dir makes the probe SUCCEED -- the exact opposite of a clean Mac,
|
||||
# where DEVELOPER_DIR is unset and the missing /var/db/xcode_select_link is what
|
||||
# makes `xcode-select -p` fail.
|
||||
# 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"
|
||||
|
|
@ -88,11 +81,10 @@ if [ "$MODE" = "mask" ]; then
|
|||
} >> "$ENV_FILE"
|
||||
|
||||
if [ "$REMOVE" = "1" ] && [ "$OS" = "Darwin" ]; then
|
||||
# Best-effort real removal. Each step is independent and recorded in
|
||||
# restore.sh so an `if: always()` step can put the runner back.
|
||||
# /var/db/xcode_select_link is exactly what `xcode-select -p` reads, so
|
||||
# removing it reproduces a virgin Mac's gate precisely. `xcode-select --reset`
|
||||
# is NOT enough: it can reselect a full Xcode.app.
|
||||
# Best-effort real removal; each step is independent and recorded in restore.sh
|
||||
# so an `if: always()` step can put the runner back. xcode_select_link is exactly
|
||||
# what `xcode-select -p` reads, so removing it reproduces a virgin Mac's gate.
|
||||
# `xcode-select --reset` is NOT enough: it can reselect a full Xcode.app.
|
||||
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"
|
||||
|
|
@ -101,8 +93,8 @@ if [ "$MODE" = "mask" ]; then
|
|||
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.
|
||||
# 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"
|
||||
|
|
@ -111,11 +103,11 @@ if [ "$MODE" = "mask" ]; then
|
|||
note "WARN could not move CommandLineTools"
|
||||
fi
|
||||
fi
|
||||
# Xcode.app must go too. With the select link removed AND CommandLineTools moved,
|
||||
# `xcode-select -p` does not fail -- it falls through to whatever Xcode bundle the
|
||||
# runner image ships (observed: /Applications/Xcode_16.4.app/Contents/Developer),
|
||||
# which re-arms /usr/bin/git and /usr/bin/cc and silently un-cleans the machine.
|
||||
# A rename is instant regardless of bundle size: same filesystem, no copy.
|
||||
# Xcode.app must go too: with the link removed AND CommandLineTools moved,
|
||||
# `xcode-select -p` still does not fail, it falls through to the image's Xcode
|
||||
# bundle (observed: /Applications/Xcode_16.4.app/Contents/Developer), which
|
||||
# re-arms /usr/bin/{git,cc} and silently un-cleans the machine. A rename is
|
||||
# instant regardless of bundle size: same filesystem, no copy.
|
||||
for app in /Applications/Xcode*.app; do
|
||||
[ -d "$app" ] || continue
|
||||
if sudo mv "$app" "${app}.masked" 2>/dev/null; then
|
||||
|
|
@ -143,8 +135,8 @@ if [ "$MODE" = "trace" ]; then
|
|||
for tool in $TOOLS; do
|
||||
real="$(command -v "$tool" 2>/dev/null || true)"
|
||||
[ -n "$real" ] || continue
|
||||
# Wrapper logs the call then execs the REAL binary, so behaviour is unchanged
|
||||
# and the trace answers "did the installer reach for this?" honestly.
|
||||
# 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"
|
||||
|
|
|
|||
232
.github/workflows/clean-machine-install-ci.yml
vendored
232
.github/workflows/clean-machine-install-ci.yml
vendored
|
|
@ -4,14 +4,12 @@
|
|||
# Proves Unsloth installs on a machine that has never seen a developer toolchain.
|
||||
#
|
||||
# Why this exists: studio-mac-install-matrix.yml runs `install.sh --local --no-torch`
|
||||
# on runners that already have Xcode CLT selected AND actions/setup-python
|
||||
# preinstalled, so the macOS dependency gate in install.sh never fires there -- and
|
||||
# `--local` is precisely the mode that legitimately needs git. A brand-new Mac
|
||||
# therefore hits a hard `exit 1` that no CI job covered.
|
||||
# on runners with Xcode CLT selected AND actions/setup-python preinstalled, so the
|
||||
# macOS dependency gate never fires there -- and `--local` is precisely the mode that
|
||||
# legitimately needs git. A brand-new Mac hits a hard `exit 1` no CI job covered.
|
||||
#
|
||||
# Hosted runners are developer machines, so each job simulates absence rather than
|
||||
# being virgin. Two modes, because they answer different questions and cannot be
|
||||
# done by the same mechanism (see .github/scripts/clean-machine-env.sh):
|
||||
# Hosted runners are developer machines, so each job simulates absence. Two modes,
|
||||
# answering different questions (see .github/scripts/clean-machine-env.sh):
|
||||
# mask -> the toolchain is genuinely unusable; does the install still work?
|
||||
# trace -> the toolchain works but is logged; does the installer ever call it?
|
||||
# Linux is the exception: containers are genuinely clean.
|
||||
|
|
@ -55,8 +53,8 @@ env:
|
|||
# No wildcard bind -> no ifconfig.me / check-host.net calls on the startup path.
|
||||
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
|
||||
# Without this, run_install_cmd (install.sh:193-243) sends every `uv pip install`
|
||||
# to a temp file and DELETES it on success, so logs/install.log holds no uv output
|
||||
# and the `nobuild` assertion can only ever report "built: none".
|
||||
# to a temp file and DELETES it on success, so the `nobuild` assertion can only
|
||||
# ever report "built: none".
|
||||
UNSLOTH_VERBOSE: '1'
|
||||
|
||||
jobs:
|
||||
|
|
@ -66,14 +64,14 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 40
|
||||
continue-on-error: ${{ matrix.experimental }}
|
||||
# Explicit legs rather than a full cross-product: the interesting dimensions are
|
||||
# Explicit legs, not a full cross-product: the interesting dimensions are
|
||||
# (does the toolchain exist) x (how the script is delivered), not every pairing.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# The reported failure, in the shape users run it. Default install (torch
|
||||
# included) because that is what a consumer actually gets.
|
||||
# The reported failure, in the shape users run it. Default install (with
|
||||
# torch) because that is what a consumer actually gets.
|
||||
- {os: macos-14, mode: mask, delivery: pipe, flags: '', experimental: false}
|
||||
- {os: macos-14, mode: mask, delivery: file, flags: '', experimental: false}
|
||||
# What the desktop app runs: no tty, stdin closed, TAURI markers on.
|
||||
|
|
@ -82,9 +80,8 @@ jobs:
|
|||
- {os: macos-14, mode: trace, delivery: file, flags: '', experimental: false}
|
||||
# --no-torch is the one macOS path that can still want a compiler
|
||||
# (sentencepiece has no guaranteed cp313 arm64 wheel), so probe it apart
|
||||
# from the default path instead of letting it mask the gate under test.
|
||||
# from the default path instead of letting it hide the gate under test.
|
||||
- {os: macos-14, mode: mask, delivery: file, flags: '--no-torch', experimental: true}
|
||||
# OS-version dimension.
|
||||
- {os: macos-15, mode: mask, delivery: pipe, flags: '', experimental: false}
|
||||
- {os: macos-26, mode: mask, delivery: file, flags: '', experimental: true}
|
||||
# Intel pins python 3.12 and its /usr/bin/git is not CLT-provided, so it
|
||||
|
|
@ -97,8 +94,8 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Deliberately no actions/setup-python: install.sh must bring its own
|
||||
# uv-managed CPython, exactly as it must on a user's machine.
|
||||
# No actions/setup-python on purpose: install.sh must bring its own uv-managed
|
||||
# CPython, exactly as it must on a user's machine.
|
||||
|
||||
- name: Record the pre-masking toolchain
|
||||
run: |
|
||||
|
|
@ -139,18 +136,17 @@ jobs:
|
|||
FLAGS="${{ matrix.flags }}"
|
||||
case "${{ matrix.delivery }}" in
|
||||
file)
|
||||
# Plain file execution: isolates "installer logic broken" from
|
||||
# Plain file execution isolates "installer logic broken" from
|
||||
# "curl-pipe delivery broken".
|
||||
bash install.sh $FLAGS 2>&1 | tee logs/install.log || rc=$?
|
||||
;;
|
||||
pipe)
|
||||
# The shape users actually run. install.sh is ~150KB of top-level
|
||||
# statements, so an early `exit` leaves the writer with a closed
|
||||
# pipe -> `curl: (56)`. Piping a local file reproduces that
|
||||
# faithfully without depending on unsloth.ai being current.
|
||||
# On pull_request/push this input is empty, which correctly falls through to
|
||||
# the checked-out ref -- only an explicit dispatch tests unsloth.ai.
|
||||
if [ "${{ inputs.installer_source }}" = "published" ]; then
|
||||
# statements, so an early `exit` leaves the writer with a closed pipe
|
||||
# -> `curl: (56)`. Piping a local file reproduces that faithfully
|
||||
# without depending on unsloth.ai being current. This input is empty on
|
||||
# pull_request/push, so only an explicit dispatch tests unsloth.ai.
|
||||
if [ "${{ inputs.installer_source }}" = "published" ]; then
|
||||
curl -fsSL https://unsloth.ai/install.sh | sh -s -- $FLAGS 2>&1 | tee logs/install.log || rc=$?
|
||||
else
|
||||
# `sh -s --` with no further args would pass an empty positional,
|
||||
|
|
@ -164,10 +160,10 @@ jobs:
|
|||
;;
|
||||
tauri)
|
||||
# Exactly how the desktop app invokes it: no tty, stdin closed.
|
||||
# --tauri rejects a custom UNSLOTH_STUDIO_HOME outright (the desktop app
|
||||
# still uses the legacy ~/.unsloth/studio root), so the workspace-scoped
|
||||
# value every other leg relies on has to go here or the installer exits
|
||||
# before it does any work. The runner is ephemeral, so the real home is
|
||||
# --tauri rejects a custom UNSLOTH_STUDIO_HOME outright (it still uses
|
||||
# the legacy ~/.unsloth/studio root), so the workspace-scoped value
|
||||
# every other leg relies on must be dropped or the installer exits
|
||||
# before doing any work. The runner is ephemeral, so the real home is
|
||||
# as disposable as the override.
|
||||
env -u UNSLOTH_STUDIO_HOME \
|
||||
bash install.sh --tauri $FLAGS < /dev/null 2>&1 | tee logs/install.log || rc=$?
|
||||
|
|
@ -175,8 +171,8 @@ jobs:
|
|||
esac
|
||||
echo "install_rc=$rc" >> "$GITHUB_OUTPUT"
|
||||
echo "installer exit code: $rc"
|
||||
# The pipe legs are the ones that expose curl:(56); surface it explicitly
|
||||
# rather than leaving it buried in a 4000-line log.
|
||||
# The pipe legs expose curl:(56); surface it rather than leaving it buried
|
||||
# in a 4000-line log.
|
||||
if grep -qE "curl: \(5[36]\)|Failure writing output to destination" logs/install.log; then
|
||||
echo "::warning::curl reported a broken pipe -- an early exit killed the reader"
|
||||
fi
|
||||
|
|
@ -194,9 +190,9 @@ jobs:
|
|||
if: steps.install.outcome == 'success'
|
||||
run: |
|
||||
set -a; . ./clean-machine.env; set +a
|
||||
# The tauri leg cannot honour UNSLOTH_STUDIO_HOME (see the Install step), so
|
||||
# it installed into the legacy root. llama.cpp sits at <root>/llama.cpp and
|
||||
# the venv at <root>/studio, so this is ~/.unsloth, not ~/.unsloth/studio.
|
||||
# The tauri leg cannot honour UNSLOTH_STUDIO_HOME (see Install), so it went
|
||||
# to the legacy root: llama.cpp sits at <root>/llama.cpp and the venv at
|
||||
# <root>/studio, so this is ~/.unsloth, not ~/.unsloth/studio.
|
||||
if [ "${{ matrix.delivery }}" = "tauri" ]; then
|
||||
HOME_DIR="$HOME/.unsloth"
|
||||
else
|
||||
|
|
@ -212,8 +208,8 @@ jobs:
|
|||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
# The rows at lines 74 and 82 differ only in `flags`, so the flags have to
|
||||
# be in the name: artifacts are immutable per run and the second upload 409s.
|
||||
# Two matrix rows differ only in `flags`, so flags must be in the name:
|
||||
# artifacts are immutable per run and the second upload 409s.
|
||||
name: clean-mac-${{ matrix.os }}-${{ matrix.mode }}-${{ matrix.delivery }}${{ matrix.flags && format('-{0}', matrix.flags) || '' }}
|
||||
path: |
|
||||
logs/
|
||||
|
|
@ -230,8 +226,8 @@ jobs:
|
|||
container: ${{ matrix.image }}
|
||||
timeout-minutes: 40
|
||||
continue-on-error: ${{ matrix.experimental }}
|
||||
# Container jobs default to `sh -e`, which is dash: `set -o pipefail` is an
|
||||
# "Illegal option" there and kills the step before the installer even starts.
|
||||
# Container jobs default to `sh -e` (dash), where `set -o pipefail` is an
|
||||
# "Illegal option" that kills the step before the installer even starts.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
|
@ -250,7 +246,7 @@ jobs:
|
|||
runner: ubuntu-24.04-arm
|
||||
experimental: false
|
||||
# No elevation: today this hard-fails at install.sh:856-861. Expected
|
||||
# failure -- the point is to pin the message and prove it is actionable
|
||||
# failure; the point is to pin the message and prove it is actionable
|
||||
# rather than a bare `curl: (56)`.
|
||||
- label: ubuntu2404-nonroot
|
||||
image: ubuntu:24.04
|
||||
|
|
@ -270,9 +266,9 @@ jobs:
|
|||
printf '%-8s %s\n' "$t" "$(command -v $t 2>/dev/null || echo ABSENT)"
|
||||
done | tee /tmp/container-baseline.txt
|
||||
|
||||
# The advertised `curl | sh` cannot even start on an image without curl, so
|
||||
# the bootstrap transport is provisioned separately from the installer's own
|
||||
# dependencies. Everything else stays absent.
|
||||
# The advertised `curl | sh` cannot even start on an image without curl, so the
|
||||
# transport is provisioned apart from the installer's own dependencies.
|
||||
# Everything else stays absent.
|
||||
- name: Provision only the bootstrap transport
|
||||
run: |
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
|
|
@ -281,17 +277,16 @@ jobs:
|
|||
dnf install -y -q ca-certificates curl
|
||||
fi
|
||||
|
||||
# No actions/checkout here on purpose: it requires git, and a container with git
|
||||
# preinstalled is not the clean machine under test. Fetch the two files we need
|
||||
# over the transport provisioned above -- and fetch the INSTALLER from the same
|
||||
# ref, so these legs can validate a fix instead of only the published script.
|
||||
# No actions/checkout on purpose: it needs git, and a container with git
|
||||
# preinstalled is not the clean machine under test. Fetch over the transport
|
||||
# above, and fetch the INSTALLER from the same ref so these legs can validate a
|
||||
# fix instead of only the published script.
|
||||
- name: Fetch installer + assert script for this ref
|
||||
run: |
|
||||
mkdir -p logs .github/scripts
|
||||
raw="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${GITHUB_SHA}"
|
||||
curl -fsSL "$raw/.github/scripts/clean-machine-assert.sh" -o .github/scripts/clean-machine-assert.sh
|
||||
# On pull_request/push this input is empty, which correctly falls through to
|
||||
# the checked-out ref -- only an explicit dispatch tests unsloth.ai.
|
||||
# Empty on pull_request/push, so only an explicit dispatch tests unsloth.ai.
|
||||
if [ "${{ inputs.installer_source }}" = "published" ]; then
|
||||
curl -fsSL https://unsloth.ai/install.sh -o install.sh
|
||||
echo "installer: published (unsloth.ai)"
|
||||
|
|
@ -305,12 +300,11 @@ jobs:
|
|||
if: matrix.label == 'ubuntu2404-nonroot'
|
||||
run: |
|
||||
useradd -m tester
|
||||
# Switching user without a login shell keeps the caller's environment, so the
|
||||
# workflow-wide UNSLOTH_STUDIO_HOME follows tester in -- and install.sh both
|
||||
# resolves AND validates that override in _resolve_studio_destinations
|
||||
# (install.sh:503-559), which runs long before the elevation gate at
|
||||
# install.sh:840-861. Without a writable target this leg dies on
|
||||
# "cannot be created" instead of on "cannot elevate".
|
||||
# Switching user without a login shell keeps the caller's environment, so
|
||||
# the workflow-wide UNSLOTH_STUDIO_HOME follows tester in, and install.sh
|
||||
# validates that override in _resolve_studio_destinations (503-559), long
|
||||
# before the elevation gate (840-861). Without a writable target this leg
|
||||
# dies on "cannot be created" instead of on "cannot elevate".
|
||||
mkdir -p "$UNSLOTH_STUDIO_HOME"
|
||||
# No sudo installed and not root -> exercises the "cannot elevate" branch.
|
||||
chown -R tester logs install.sh "$UNSLOTH_STUDIO_HOME"
|
||||
|
|
@ -321,8 +315,8 @@ jobs:
|
|||
run: |
|
||||
set -o pipefail
|
||||
rc=0
|
||||
# Piped, because that is the advertised command and the shape that turns an
|
||||
# early exit into curl:(56).
|
||||
# Piped: the advertised command, and the shape that turns an early exit
|
||||
# into curl:(56).
|
||||
cat install.sh | sh 2>&1 | tee logs/install.log || rc=$?
|
||||
echo "installer exit code: $rc"
|
||||
exit "$rc"
|
||||
|
|
@ -335,16 +329,16 @@ jobs:
|
|||
su tester -c 'cat install.sh | sh' > logs/install.log 2>&1 || rc=$?
|
||||
echo "installer exit code: $rc"
|
||||
tail -40 logs/install.log
|
||||
# It may legitimately fail; what must NOT happen is an unexplained exit or a
|
||||
# bare broken-pipe error standing in for a real diagnosis.
|
||||
# It may legitimately fail; what must NOT happen is an unexplained exit or
|
||||
# a bare broken-pipe error standing in for a real diagnosis.
|
||||
if [ "$rc" != "0" ] && ! grep -qiE "sudo is not available|apt-get install|missing:|permission" logs/install.log; then
|
||||
echo "::error::unprivileged install failed with no actionable message"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The nonroot leg checks that its expected failure is the expected one. This leg
|
||||
# is continue-on-error too, so without the same check a bootstrap outage or an
|
||||
# unrelated early exit is tolerated exactly like the intentional diagnostic.
|
||||
# This leg is continue-on-error like the nonroot one, so without the same check
|
||||
# a bootstrap outage or an unrelated early exit would be tolerated exactly like
|
||||
# the intentional diagnostic.
|
||||
- name: Assert the Fedora failure is the unsupported-package-manager one
|
||||
if: always() && matrix.label == 'fedora41'
|
||||
run: |
|
||||
|
|
@ -360,8 +354,8 @@ jobs:
|
|||
fi
|
||||
|
||||
# nobuild only reads the log, so an installer that exits 0 having done nothing
|
||||
# satisfies it. These are the required Linux rows and, unlike the WSL and
|
||||
# Windows jobs, they had no check that the install produced anything runnable.
|
||||
# satisfies it. These required Linux rows had no check that the install
|
||||
# produced anything runnable, unlike the WSL and Windows jobs.
|
||||
- name: Assert the install is actually usable
|
||||
if: steps.install_root.outcome == 'success'
|
||||
run: |
|
||||
|
|
@ -392,13 +386,13 @@ jobs:
|
|||
|
||||
# ── WSL ───────────────────────────────────────────────────────────────────
|
||||
# install.sh carries ~126 lines of WSL-specific logic (the `linux|wsl` dependency
|
||||
# branch, UNSLOTH_WSL_REROUTED, the Strix Halo reroute to 24.04), and none of it
|
||||
# had ever run in CI -- tests/sh/test_strixhalo_wsl_reroute.sh extracts functions
|
||||
# and mocks the environment, which cannot catch anything about a real WSL.
|
||||
# branch, UNSLOTH_WSL_REROUTED, the Strix Halo reroute to 24.04) that had never run
|
||||
# in CI: tests/sh/test_strixhalo_wsl_reroute.sh extracts functions and mocks the
|
||||
# environment, which cannot catch anything about a real WSL.
|
||||
#
|
||||
# No third-party action: the official Ubuntu WSL rootfs plus `wsl --import` is
|
||||
# deterministic, checksum-verifiable, and avoids adding a supply-chain dependency
|
||||
# to a repo that audits its lockfiles.
|
||||
# No third-party action: the official Ubuntu rootfs plus `wsl --import` is
|
||||
# deterministic and checksum-verifiable, and adds no supply-chain dependency to a
|
||||
# repo that audits its lockfiles.
|
||||
wsl:
|
||||
name: wsl ubuntu-24.04
|
||||
runs-on: windows-latest
|
||||
|
|
@ -426,25 +420,23 @@ jobs:
|
|||
}
|
||||
wsl --import unsloth-ci "$PWD/wsl-dist/instance" "$PWD/wsl-dist/rootfs.tar.gz" --version 2
|
||||
wsl -d unsloth-ci -- uname -a
|
||||
# A freshly imported rootfs is genuinely bare: no curl, no git, no compiler.
|
||||
# A freshly imported rootfs is genuinely bare: no curl, git or compiler.
|
||||
# That is the clean machine, not a simulation of one.
|
||||
wsl -d unsloth-ci -- sh -c 'for t in curl wget git gcc cmake python3 sudo; do printf "%-8s %s\n" "$t" "$(command -v $t || echo ABSENT)"; done'
|
||||
|
||||
- name: Install inside WSL, piped exactly as documented
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Only ca-certificates + curl, because the advertised one-liner cannot even
|
||||
# start without a transport. Everything else must come from the installer.
|
||||
# Only ca-certificates + curl: the advertised one-liner cannot start without
|
||||
# a transport. Everything else must come from the installer.
|
||||
wsl -d unsloth-ci -u root -- sh -c 'apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl' 2>&1 | Tee-Object -FilePath logs/wsl-bootstrap.log
|
||||
# Copy the script in rather than reaching across /mnt/c: a DrvFs path brings
|
||||
# Windows file permissions and CRLF risk with it, and neither is what a real
|
||||
# WSL user's install looks like.
|
||||
# Windows permissions and CRLF risk, neither of which a real WSL user has.
|
||||
$wslPath = (wsl -d unsloth-ci -- wslpath -a "$($env:GITHUB_WORKSPACE -replace '\\','/')/install.sh").Trim()
|
||||
Write-Host "installer source in WSL: $wslPath"
|
||||
wsl -d unsloth-ci -u root -- cp "$wslPath" /root/install.sh
|
||||
# Feed it through a pipe: same shape as `curl ... | sh`, so an early exit
|
||||
# still exposes the broken-pipe problem, but the script under test is this
|
||||
# ref rather than whatever production currently serves.
|
||||
# Piped, same shape as `curl ... | sh`, so an early exit still exposes the
|
||||
# broken pipe, but the script under test is this ref not production's.
|
||||
wsl -d unsloth-ci -u root -- sh -c 'cd /root && cat install.sh | sh' 2>&1 | Tee-Object -FilePath logs/wsl-install.log
|
||||
Write-Host "installer exit: $LASTEXITCODE"
|
||||
|
||||
|
|
@ -455,11 +447,11 @@ jobs:
|
|||
# The platform line proves the wsl branch was taken rather than plain linux.
|
||||
Select-String -Path logs/wsl-install.log -Pattern 'platform|\[TAURI:DIAG\]|wsl' -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 10
|
||||
# Printing could not fail, and that alternation also matches `platform linux`.
|
||||
# If detection regresses, every WSL-specific branch is skipped and this job
|
||||
# still passes as a plain-Linux install, which is the one thing no other job
|
||||
# covers. `step` writes the label in reverse video, so strip ANSI first or an
|
||||
# anchored match can never hit.
|
||||
# Printing could not fail, and that alternation also matches
|
||||
# `platform linux`: if detection regresses, every WSL branch is skipped and
|
||||
# this job still passes as a plain-Linux install, the one thing no other job
|
||||
# covers. `step` writes the label in reverse video, so strip ANSI first or
|
||||
# an anchored match can never hit.
|
||||
$esc = [char]27
|
||||
$platformLines = @(
|
||||
Get-Content logs/wsl-install.log -ErrorAction SilentlyContinue |
|
||||
|
|
@ -472,8 +464,8 @@ jobs:
|
|||
exit 1
|
||||
}
|
||||
# No `|| echo`: substituting a message for the missing CLI made the inner
|
||||
# shell -- and so this step, and so the job -- succeed even when the install
|
||||
# produced nothing usable, which is the half of the question this step asks.
|
||||
# shell, this step and the job all succeed even when the install produced
|
||||
# nothing usable, which is half of what this step asks.
|
||||
$verify = wsl -d unsloth-ci -u root -- sh -c 'set -e; test -x "$HOME/.unsloth/studio/unsloth_studio/bin/unsloth"; "$HOME/.unsloth/studio/unsloth_studio/bin/unsloth" --version' 2>&1
|
||||
$verifyRc = $LASTEXITCODE
|
||||
$verify | Tee-Object -FilePath logs/wsl-verify.log
|
||||
|
|
@ -509,10 +501,10 @@ jobs:
|
|||
- os: windows-latest
|
||||
winget: 'visible'
|
||||
experimental: false
|
||||
# The no-winget path (LTSC / Server / managed corporate machines) falls
|
||||
# back to python.org + astral.sh and is completely untested today. It is
|
||||
# also the path where Ensure-VCRedist silently does not run, which leaves
|
||||
# torch unable to load -- hence the explicit `import torch` assert below.
|
||||
# The no-winget path (LTSC / Server / managed corporate machines) falls back
|
||||
# to python.org + astral.sh and is untested today. It is also where
|
||||
# Ensure-VCRedist silently does not run, leaving torch unable to load --
|
||||
# hence the explicit `import torch` assert below.
|
||||
- os: windows-latest
|
||||
winget: 'masked'
|
||||
experimental: false
|
||||
|
|
@ -531,20 +523,20 @@ jobs:
|
|||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path logs | Out-Null
|
||||
# Drop preinstalled Python, git, CMake, VS/LLVM and the WindowsApps
|
||||
# aliases from PATH. A full Visual Studio uninstall is not realistic in
|
||||
# CI (registry + vswhere discovery, slow, may need a reboot), so PATH and
|
||||
# env scrubbing is the honest approximation -- recorded as such.
|
||||
# Drop preinstalled Python, git, CMake, VS/LLVM and the WindowsApps aliases
|
||||
# from PATH. A full Visual Studio uninstall is not realistic in CI (registry
|
||||
# + vswhere discovery, slow, may need a reboot), so PATH and env scrubbing
|
||||
# is the honest approximation, recorded as such.
|
||||
$drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\',
|
||||
'CMake', 'Microsoft Visual Studio', 'BuildTools', 'LLVM',
|
||||
'MSYS', 'mingw', 'Strawberry')
|
||||
# winget ships as an app-execution alias inside ...\Local\Microsoft\WindowsApps,
|
||||
# which the blanket drop above removes on EVERY leg -- so winget=visible was
|
||||
# winget is an app-execution alias in ...\Local\Microsoft\WindowsApps, which
|
||||
# the blanket drop above removes on EVERY leg -- so winget=visible was
|
||||
# silently running the same no-winget fallback as winget=masked. Resolve it
|
||||
# before the scrub and hand it back through a shim, so the visible leg gets
|
||||
# winget without also getting the Store's python.exe alias back.
|
||||
# windows-11-arm has no winget at all on the hosted image
|
||||
# (actions/runner-images#14083), so only windows-latest can carry it.
|
||||
# winget without the Store's python.exe alias back. windows-11-arm has no
|
||||
# winget on the hosted image (actions/runner-images#14083), so only
|
||||
# windows-latest can carry it.
|
||||
$wantWinget = ('${{ matrix.winget }}' -ne 'masked') -and ('${{ matrix.os }}' -eq 'windows-latest')
|
||||
$wingetCmd = Get-Command winget -ErrorAction SilentlyContinue
|
||||
$scrub = {
|
||||
|
|
@ -569,10 +561,10 @@ jobs:
|
|||
-Value "@`"$($wingetCmd.Source)`" %*"
|
||||
$kept = @($shim) + $kept
|
||||
}
|
||||
# Take the toolcache Python off disk, not just off PATH. py.exe lives in
|
||||
# Take the toolcache Python off disk, not just off PATH: py.exe lives in
|
||||
# C:\Windows (which must stay) and uv does its own interpreter discovery, so
|
||||
# both reach the toolcache no matter what PATH says -- which is how a leg
|
||||
# printing `python ABSENT` still installed with the runner's 3.13.14.
|
||||
# both reach the toolcache whatever PATH says -- which is how a leg printing
|
||||
# `python ABSENT` still installed with the runner's 3.13.14.
|
||||
foreach ($tc in @("$env:AGENT_TOOLSDIRECTORY\Python", 'C:\hostedtoolcache\windows\Python')) {
|
||||
if ($tc -and (Test-Path $tc)) {
|
||||
try { Rename-Item -LiteralPath $tc -NewName 'Python.masked' -ErrorAction Stop
|
||||
|
|
@ -582,13 +574,13 @@ jobs:
|
|||
}
|
||||
$newPath = ($kept -join ';')
|
||||
"PATH=$newPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
# install.ps1 calls Refresh-SessionPath (defined install.ps1:318-337, called at
|
||||
# 1246/1278/1295/1360/1369/2797), which rebuilds $env:Path from the Machine and
|
||||
# User registry values. Scrubbing only the process PATH therefore lasts until
|
||||
# the first bootstrap refresh, after which Git/CMake/VS/LLVM are back and the
|
||||
# rest of the install is no longer running on a simulated clean machine.
|
||||
# The runner is ephemeral, so rewrite the registry copies too. Expand first:
|
||||
# SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ (dotnet/runtime#1442).
|
||||
# install.ps1's Refresh-SessionPath (318-337, called at 1246/1278/1295/1360/
|
||||
# 1369/2797) rebuilds $env:Path from the Machine and User registry values, so
|
||||
# scrubbing only the process PATH lasts until the first bootstrap refresh,
|
||||
# after which Git/CMake/VS/LLVM are back and the rest of the install is no
|
||||
# longer clean. The runner is ephemeral, so rewrite the registry copies too.
|
||||
# Expand first: SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ
|
||||
# (dotnet/runtime#1442).
|
||||
foreach ($scope in 'Machine','User') {
|
||||
$raw = [System.Environment]::GetEnvironmentVariable('Path', $scope)
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) { continue }
|
||||
|
|
@ -610,15 +602,15 @@ jobs:
|
|||
run: |
|
||||
$leaked = @()
|
||||
# `py` too: the launcher lives in C:\Windows, which the scrub keeps, and it
|
||||
# finds the toolcache Python the scrub just removed from PATH.
|
||||
# finds the toolcache Python that the scrub only removed from PATH.
|
||||
foreach ($t in 'python','py','git','cmake','cl') {
|
||||
$f = Get-Command $t -ErrorAction SilentlyContinue
|
||||
Write-Host ("{0,-8} {1}" -f $t, $(if ($f) { $f.Source } else { 'ABSENT' }))
|
||||
if ($f -and $t -ne 'py') { $leaked += "$t -> $($f.Source)" }
|
||||
}
|
||||
# Printing alone could not fail, and the leg was green while not clean:
|
||||
# run 30365014702 logged `python ABSENT` and then `Python 3.13 already
|
||||
# installed` / `Using CPython ... C:\hostedtoolcache\windows\Python\...`.
|
||||
# Printing alone could not fail, and the leg was green while not clean: run
|
||||
# 30365014702 logged `python ABSENT` then `Python 3.13 already installed` /
|
||||
# `Using CPython ... C:\hostedtoolcache\windows\Python\...`.
|
||||
if ($leaked) {
|
||||
Write-Host "::error::developer tooling survived the scrub: $($leaked -join '; ')"
|
||||
exit 1
|
||||
|
|
@ -631,8 +623,8 @@ jobs:
|
|||
exit 1
|
||||
}
|
||||
} elseif ('${{ matrix.os }}' -eq 'windows-latest' -and -not $winget) {
|
||||
# Without this the visible leg quietly degrades into a second masked leg and
|
||||
# the normal winget bootstrap is never exercised by any job in this workflow.
|
||||
# Without this the visible leg quietly degrades into a second masked leg
|
||||
# and no job in this workflow exercises the normal winget bootstrap.
|
||||
Write-Host '::error::winget is not resolvable on the visible leg; the winget bootstrap is not under test'
|
||||
exit 1
|
||||
}
|
||||
|
|
@ -646,9 +638,9 @@ jobs:
|
|||
run: |
|
||||
$ErrorActionPreference = 'Continue'
|
||||
# No -SkipTorch: install.ps1 has no param block and its parser matches
|
||||
# `--no-torch` only (install.ps1:112-142), so the token was silently dropped
|
||||
# and every Windows leg installed torch anyway. Torch is exactly what the
|
||||
# assert below needs, so ask for it explicitly rather than by accident.
|
||||
# `--no-torch` only (112-142), so the token was silently dropped and every
|
||||
# Windows leg installed torch anyway. The assert below needs torch, so ask
|
||||
# for it explicitly rather than by accident.
|
||||
& ./install.ps1 *>&1 | Tee-Object -FilePath logs/install.log
|
||||
$rc = $LASTEXITCODE
|
||||
Write-Host "installer exit code: $rc"
|
||||
|
|
@ -659,12 +651,12 @@ jobs:
|
|||
shell: pwsh
|
||||
run: |
|
||||
# HONESTY NOTE: the hosted image ships the VC++ 2015-2022 runtime in System32
|
||||
# and it cannot be removed without breaking the runner, so a successful
|
||||
# `import torch` here does NOT prove that a genuinely clean no-winget machine
|
||||
# would have the runtime -- Test-VCRedistInstalled (studio/setup.ps1:875)
|
||||
# finds the preinstalled DLL and Ensure-VCRedist (setup.ps1:891) short-circuits
|
||||
# before it ever needs winget. Record that, then assert what CAN fail here:
|
||||
# torch imports, and the masked leg really did take the no-winget path.
|
||||
# and it cannot be removed without breaking the runner, so `import torch`
|
||||
# succeeding here does NOT prove a genuinely clean no-winget machine has the
|
||||
# runtime: Test-VCRedistInstalled (studio/setup.ps1:875) finds the
|
||||
# preinstalled DLL and Ensure-VCRedist (891) short-circuits before it needs
|
||||
# winget. Record that, then assert what CAN fail: torch imports, and the
|
||||
# masked leg really did take the no-winget path.
|
||||
$sys32 = Join-Path $env:WINDIR 'System32\vcruntime140_1.dll'
|
||||
Write-Host "preinstalled System32 vcruntime140_1.dll: $(Test-Path $sys32)"
|
||||
$py = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\python.exe'
|
||||
|
|
|
|||
109
.github/workflows/desktop-app-clean-machine-ci.yml
vendored
109
.github/workflows/desktop-app-clean-machine-ci.yml
vendored
|
|
@ -4,29 +4,26 @@
|
|||
# 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; release-desktop.yml produces the
|
||||
# bundles. Neither takes a published artifact, puts it on a clean machine, and checks
|
||||
# that it starts -- which is exactly the gap the reported failures fell through: both
|
||||
# came from the packaged app running its bundled
|
||||
# Contents/Resources/install.sh, a path no CI job exercised.
|
||||
# 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.
|
||||
#
|
||||
# What "runs" means here, given hosted runners have no interactive desktop session:
|
||||
# - the bundle installs / mounts / extracts
|
||||
# - the binary is present, of the right architecture, and passes the OS gatekeeper
|
||||
# checks a user would hit (macOS quarantine + codesign, Windows installer exit)
|
||||
# - the process starts and STAYS UP past its preflight (it does not exit or crash),
|
||||
# which is where an unhappy app dies
|
||||
# - it writes tauri.log, and that log shows the preflight disposition -- the same
|
||||
# field that read `ManagedReady` over an unbootable venv in the bug report
|
||||
# Linux gets the strongest check: a real webview under Xvfb.
|
||||
# Hosted runners have no interactive desktop session, so "runs" means: the bundle
|
||||
# installs / mounts / extracts, the binary is present, of the right architecture, and
|
||||
# passes the gatekeeper checks a user would hit (macOS quarantine + codesign, Windows
|
||||
# installer exit), the process STAYS UP past its preflight (where an unhappy app dies),
|
||||
# and it writes tauri.log showing the preflight disposition -- the field that read
|
||||
# `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 that touch this job or the machine-stripping scripts. workflow_dispatch
|
||||
# alone is not enough to validate a change to the job itself: dispatch resolves the
|
||||
# Also on PRs touching this job or the machine-stripping scripts: workflow_dispatch
|
||||
# alone cannot validate a change to the job itself, because dispatch resolves the
|
||||
# workflow from the DEFAULT branch, so a new or edited file on a feature branch can
|
||||
# never be dispatched, and the job would first run only after merging blind.
|
||||
# never be dispatched and would first run only after merging blind.
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/desktop-app-clean-machine-ci.yml'
|
||||
|
|
@ -59,8 +56,8 @@ permissions:
|
|||
|
||||
env:
|
||||
REL_REPO: ${{ inputs.release_repo || 'unsloth-test/unsloth-test' }}
|
||||
# Empty unless dispatched. A pinned tag is an immutable fixture, so a nightly
|
||||
# against it can never catch a newly published broken bundle; each download step
|
||||
# Empty unless dispatched: a pinned tag is an immutable fixture, so a nightly
|
||||
# against it could never catch a newly published broken bundle. Each download step
|
||||
# resolves the newest desktop-v* release when this is empty.
|
||||
REL_TAG: ${{ inputs.release_tag || '' }}
|
||||
UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
|
||||
|
|
@ -91,8 +88,8 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p dl logs
|
||||
# Desktop releases are prereleases (release-desktop.yml keeps them off
|
||||
# repo-wide "latest"), so resolve the newest desktop-v* tag explicitly.
|
||||
# Desktop releases are prereleases (never repo-wide "latest"), so the
|
||||
# newest desktop-v* tag has to be resolved explicitly.
|
||||
if [ -z "$REL_TAG" ]; then
|
||||
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 --exclude-drafts \
|
||||
--json tagName,createdAt \
|
||||
|
|
@ -107,11 +104,10 @@ jobs:
|
|||
ls -la dl
|
||||
|
||||
- name: Strip the developer toolchain
|
||||
# `inputs` exists only for workflow_dispatch, so on pull_request and
|
||||
# schedule `inputs.strip_toolchain` is the empty string -- and loose
|
||||
# equality coerces both '' and false to 0, making `!= false` FALSE. The
|
||||
# automatic runs would keep the hosted toolchain, which is the one thing
|
||||
# this workflow exists to remove. Gate on the event instead.
|
||||
# `inputs` exists only for workflow_dispatch, so on pull_request and schedule
|
||||
# `inputs.strip_toolchain` is '' -- and loose equality coerces both '' and
|
||||
# false to 0, making `!= false` FALSE, so automatic runs would keep the hosted
|
||||
# toolchain this workflow exists to remove. Gate on the event instead.
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
|
||||
run: |
|
||||
bash .github/scripts/clean-machine-env.sh mask --remove
|
||||
|
|
@ -121,8 +117,8 @@ jobs:
|
|||
- name: Mount and install
|
||||
run: |
|
||||
DMG="$(ls dl/*.dmg | head -1)"
|
||||
# A real download is quarantined; Gatekeeper treats it differently from a
|
||||
# locally built bundle, and that difference is a genuine failure mode.
|
||||
# A real download is quarantined, and Gatekeeper treats that differently
|
||||
# from a locally built bundle: a genuine failure mode.
|
||||
xattr -w com.apple.quarantine \
|
||||
"0081;$(printf %x $(date +%s));Safari;" "$DMG" 2>/dev/null || true
|
||||
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint /Volumes/UnslothCI
|
||||
|
|
@ -145,7 +141,7 @@ jobs:
|
|||
echo "::warning::Gatekeeper assessment failed -- users see 'cannot be opened' unless notarised"
|
||||
# The bundled installer is what actually failed for users. `::error::` is
|
||||
# only an annotation and `echo` exits 0, so the old `|| echo` form let a
|
||||
# bundle with no installer pass this step.
|
||||
# bundle with no installer pass.
|
||||
if [ -f "$APP/Contents/Resources/install.sh" ]; then
|
||||
echo "bundled install.sh present"
|
||||
else
|
||||
|
|
@ -158,22 +154,22 @@ jobs:
|
|||
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 (studio/frontend/src/hooks/use-tauri-backend.ts:252-254) and
|
||||
# startup-screen.tsx:388-389 waits for the button. Launching alone would
|
||||
# therefore sit on that screen for 90s and pass without ever running the
|
||||
# bundled installer. Invoke it the way studio/src-tauri/src/install.rs
|
||||
# does: --tauri, stdin closed, no tty. --tauri rejects a custom studio
|
||||
# home (install.sh:102-114), so drop the workspace-scoped override.
|
||||
# A headless runner never clicks Install: preflight sets `not_installed` and
|
||||
# returns (studio/frontend/src/hooks/use-tauri-backend.ts:252-254) while
|
||||
# startup-screen.tsx:388-389 waits for the button, so launching alone would
|
||||
# sit on that screen for 90s and pass without ever running the bundled
|
||||
# installer. Invoke it as studio/src-tauri/src/install.rs does: --tauri,
|
||||
# stdin closed, no tty. --tauri rejects a custom studio home
|
||||
# (install.sh:102-114), so drop the workspace-scoped override.
|
||||
env -u UNSLOTH_STUDIO_HOME \
|
||||
bash "$APP/Contents/Resources/install.sh" --tauri \
|
||||
< /dev/null 2>&1 | tee logs/bundled-install.log
|
||||
PY="$HOME/.unsloth/studio/unsloth_studio/bin/python"
|
||||
[ -x "$PY" ] || { echo "::error::bundled installer left no venv at $PY"; exit 1; }
|
||||
"$PY" -V
|
||||
# install.rs passes only --tauri, so torch is part of first launch. Dropping
|
||||
# --no-torch here and asserting torch keeps the venv check from passing over
|
||||
# a bundle whose only failure is the torch install.
|
||||
# install.rs passes only --tauri, so torch is part of first launch:
|
||||
# asserting it stops the venv check passing a bundle whose only failure is
|
||||
# the torch install.
|
||||
"$PY" -c "import torch; print('torch', torch.__version__)"
|
||||
|
||||
- name: Launch and prove it stays up
|
||||
|
|
@ -210,8 +206,8 @@ jobs:
|
|||
grep -E "disposition=|can_auto_repair=|Xcode Command Line|ModuleNotFoundError" "$f" || true
|
||||
found=1
|
||||
done
|
||||
# Everything above is `|| true`, so on its own this step could not fail while
|
||||
# the header sells the tauri.log disposition as an acceptance criterion.
|
||||
# Everything above is `|| true`, so this step could not fail while the
|
||||
# header sells the tauri.log disposition as an acceptance criterion.
|
||||
# setup_logging (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally
|
||||
# at process start, so no log at all means the binary never got that far.
|
||||
[ "${found:-0}" = "1" ] || { echo "::error::the app wrote no tauri.log; it never reached setup_logging"; exit 1; }
|
||||
|
|
@ -252,8 +248,8 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p dl logs
|
||||
# Desktop releases are prereleases (release-desktop.yml keeps them off
|
||||
# repo-wide "latest"), so resolve the newest desktop-v* tag explicitly.
|
||||
# Desktop releases are prereleases (never repo-wide "latest"), so the
|
||||
# newest desktop-v* tag has to be resolved explicitly.
|
||||
if [ -z "$REL_TAG" ]; then
|
||||
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 --exclude-drafts \
|
||||
--json tagName,createdAt \
|
||||
|
|
@ -270,9 +266,9 @@ jobs:
|
|||
- name: Install with NO dev tooling, only runtime libs
|
||||
run: |
|
||||
# Deliberately not build-essential/cmake/git: a user installing a .deb has
|
||||
# none of that. WebKit + Xvfb are runtime requirements of the app itself,
|
||||
# and apt pulls the .deb's declared deps -- if that list is wrong, this
|
||||
# step is what catches it.
|
||||
# none of that. WebKit + Xvfb are runtime requirements of the app, and apt
|
||||
# pulls the .deb's declared deps -- if that list is wrong, this step catches
|
||||
# it.
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq --no-install-recommends xvfb
|
||||
if [ "${{ matrix.kind }}" = "deb" ]; then
|
||||
|
|
@ -293,8 +289,8 @@ jobs:
|
|||
- name: Launch under Xvfb and prove it stays up
|
||||
run: |
|
||||
# Linux is the one platform where a hosted runner can give the app a real
|
||||
# display, so this is the strongest "does the UI actually come up" check
|
||||
# available without self-hosted hardware.
|
||||
# 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=$!
|
||||
|
|
@ -348,8 +344,8 @@ jobs:
|
|||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p dl logs
|
||||
# Desktop releases are prereleases (release-desktop.yml keeps them off
|
||||
# repo-wide "latest"), so resolve the newest desktop-v* tag explicitly.
|
||||
# Desktop releases are prereleases (never repo-wide "latest"), so the
|
||||
# newest desktop-v* tag has to be resolved explicitly.
|
||||
if [ -z "$REL_TAG" ]; then
|
||||
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 --exclude-drafts \
|
||||
--json tagName,createdAt \
|
||||
|
|
@ -363,11 +359,10 @@ jobs:
|
|||
ls -la dl
|
||||
|
||||
- name: Strip developer tooling from PATH
|
||||
# `inputs` exists only for workflow_dispatch, so on pull_request and
|
||||
# schedule `inputs.strip_toolchain` is the empty string -- and loose
|
||||
# equality coerces both '' and false to 0, making `!= false` FALSE. The
|
||||
# automatic runs would keep the hosted toolchain, which is the one thing
|
||||
# this workflow exists to remove. Gate on the event instead.
|
||||
# `inputs` exists only for workflow_dispatch, so on pull_request and schedule
|
||||
# `inputs.strip_toolchain` is '' -- and loose equality coerces both '' and
|
||||
# false to 0, making `!= false` FALSE, so automatic runs would keep the hosted
|
||||
# toolchain this workflow exists to remove. Gate on the event instead.
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
|
@ -382,8 +377,8 @@ jobs:
|
|||
shell: pwsh
|
||||
run: |
|
||||
$exe = (Get-ChildItem dl/*setup.exe | Select-Object -First 1).FullName
|
||||
# /S is the NSIS silent switch. A user double-clicks, but an installer that
|
||||
# cannot run unattended also cannot be scripted or MDM-deployed.
|
||||
# /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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue