unsloth/.github/workflows/clean-machine-install-ci.yml
danielhanchen 3be21e87cf Put the branch's own Python under test on the clean-machine legs
install.sh and install.ps1 come from the ref under test, but they install
unsloth from PyPI, which is the consumer path and has to stay that way. That
left everything Python-side coming out of the released wheel: studio/setup.sh,
studio/setup.ps1, studio/install_python_stack.py, and every requirements and
constraints file those resolve through Path(__file__). A branch that changes
constraints.txt or setup.ps1 therefore got a green run that proved nothing
about the change, and some legs proved less than they looked. The Fedora
assertion was already carrying a hand-written workaround for exactly this,
tolerating a triton/git failure on the grounds that the released package lags
the ref.

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

Not --local: --local additionally installs unsloth-zoo from a git+https URL,
which genuinely needs git, and git absence is the whole point of the masked
legs. The overlay resolves no dependencies and clones nothing, so it holds up
with git, cmake and the compilers all gone. It is not a consumer knob either:
no flag, no usage entry, ignored unless the variable names a directory with a
pyproject.toml in it.

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

Two supporting fixes the overlay depends on or exposes:

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

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

Each overlaid leg also asserts it really was overlaid, so an unset variable
cannot quietly put the whole matrix back on the released wheel.
2026-07-28 22:25:13 +00:00

901 lines
48 KiB
YAML

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# 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 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. 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.
#
# ── What each leg actually puts under test ────────────────────────────────────
# install.sh / install.ps1 come from this ref, but they install unsloth FROM PyPI,
# because that is the consumer path and it has to stay that way. Everything
# Python-side is therefore read out of the RELEASED wheel: studio/setup.sh,
# studio/setup.ps1, studio/install_python_stack.py, and every requirements and
# constraints file those resolve through Path(__file__). Left alone, this workflow
# can only ever validate the two shell installers, and a branch that changes
# constraints.txt or setup.ps1 gets a green run that proves nothing about the
# change. The `Assert the Fedora outcome is a known one` step below was already
# carrying a hand-written workaround for exactly this.
#
# So legs with `overlay: true` re-point the venv at this ref before studio setup
# runs, via UNSLOTH_CI_SOURCE_OVERLAY (install.sh / install.ps1, just above their
# "Run studio setup" section): a `--no-deps` editable install of the checkout.
# That makes `import studio` resolve to the working tree, so the existing
# setup-script lookup finds this ref's setup.sh / setup.ps1 and install_python_stack
# reads this ref's constraints. It is deliberately NOT `install.sh --local`:
# --local also installs `unsloth-zoo @ git+https://...`, which genuinely needs git,
# and git absence is the whole point of the masked legs. The overlay resolves no
# dependencies and clones nothing, so it still works with git, cmake and the
# compilers all gone.
#
# Legs left on `overlay: false`, and why:
# mac */mask/pipe the `curl | sh` shape a user runs. Kept end-to-end on the
# released package so a broken PyPI release still shows up.
# mac macos-14/trace `notools` asserts the installer never reaches for git, and
# the editable build itself calls `git rev-parse` /
# `git archive` through setuptools-scm's file finder. An
# overlay here would answer the leg's own question for it.
# linux ubuntu2404-nonroot dies at the elevation gate before a venv exists.
# wsl only install.sh is copied into the distro; there is no
# source tree inside WSL to overlay from.
name: Clean machine install
on:
pull_request:
paths:
- 'install.sh'
- 'install.ps1'
- 'studio/setup.sh'
- 'studio/setup.ps1'
- 'studio/install_python_stack.py'
- '.github/scripts/clean-machine-*.sh'
- '.github/workflows/clean-machine-install-ci.yml'
push:
branches: [main]
paths:
- 'install.sh'
- 'install.ps1'
- '.github/workflows/clean-machine-install-ci.yml'
workflow_dispatch:
inputs:
installer_source:
description: 'published = curl unsloth.ai/install.sh, tree = the checked-out script'
type: choice
options: [tree, published]
default: tree
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
# Keep every install inside the workspace so a leg cannot inherit another's state.
UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
# 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 the `nobuild` assertion can only
# ever report "built: none".
UNSLOTH_VERBOSE: '1'
jobs:
# ── macOS: the reported failure ────────────────────────────────────────────
macos:
name: mac ${{ matrix.os }} / ${{ matrix.mode }} / ${{ matrix.delivery }}${{ matrix.flags && format(' {0}', matrix.flags) || '' }}
runs-on: ${{ matrix.os }}
timeout-minutes: 40
continue-on-error: ${{ matrix.experimental }}
# 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:
# `overlay` decides whether this ref's Python code is put under test at all;
# see the header. The pipe legs stay on the released package on purpose.
#
# 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, overlay: false}
- {os: macos-14, mode: mask, delivery: file, flags: '', experimental: false, overlay: true}
# What the desktop app runs: no tty, stdin closed, TAURI markers on.
- {os: macos-14, mode: mask, delivery: tauri, flags: '', experimental: false, overlay: true}
# Toolchain present but logged: does the installer ever reach for it?
# No overlay: the editable build calls git itself (setuptools-scm), which
# would plant the very evidence `notools` exists to look for.
- {os: macos-14, mode: trace, delivery: file, flags: '', experimental: false, overlay: 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 hide the gate under test.
- {os: macos-14, mode: mask, delivery: file, flags: '--no-torch', experimental: true, overlay: true}
- {os: macos-15, mode: mask, delivery: pipe, flags: '', experimental: false, overlay: false}
- {os: macos-26, mode: mask, delivery: file, flags: '', experimental: true, overlay: true}
# Intel pins python 3.12 and its /usr/bin/git is not CLT-provided, so it
# survives masking. Informational only.
- {os: macos-15-intel, mode: mask, delivery: file, flags: '', experimental: true, overlay: true, allow_working: 'git'}
steps:
# checkout FIRST: it needs a working git, which masking then takes away.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# 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: |
{
echo "xcode-select -p : $(xcode-select -p 2>&1 || true)"
echo "git : $(command -v git || echo none)"
echo "brew : $(command -v brew || echo none)"
echo "cmake : $(command -v cmake || echo none)"
echo "python3 : $(command -v python3 || echo none)"
} | tee runner-baseline.txt
- name: Simulate a clean machine (${{ matrix.mode }})
run: |
mkdir -p logs
if [ "${{ matrix.mode }}" = "mask" ]; then
bash .github/scripts/clean-machine-env.sh mask --remove
else
bash .github/scripts/clean-machine-env.sh trace
fi
- name: Verify the simulation actually took effect
if: matrix.mode == 'mask'
run: |
set -a; . ./clean-machine.env; set +a
UNSLOTH_CLEAN_ALLOW_WORKING='${{ matrix.allow_working }}' \
bash .github/scripts/clean-machine-assert.sh absent
- name: Verify the trace actually records
if: matrix.mode == 'trace'
run: |
# `notools` reads an absence, so a shim dir that never reached PATH is
# indistinguishable from an installer that touched nothing, and the one leg
# carrying that assertion would pass no matter what the installer did.
# Prove the wrapper records before trusting an empty file. macOS never
# probes git off the --local path, so this must be an explicit call.
set -a; . ./clean-machine.env; set +a
[ -n "$UNSLOTH_TOOL_TRACE" ] || { echo "::error::trace mode set no UNSLOTH_TOOL_TRACE"; exit 1; }
git --version >/dev/null 2>&1 || true
if ! grep -q "^git[[:space:]]" "$UNSLOTH_TOOL_TRACE"; then
echo "::error::the trace wrapper did not record a git call, so notools proves nothing"
echo "PATH=$PATH"; command -v git; cat "$UNSLOTH_TOOL_TRACE" || true
exit 1
fi
echo "trace wrapper records; clearing the self-test entry"
: > "$UNSLOTH_TOOL_TRACE"
- name: Install
id: install
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
# Empty, and therefore ignored by install.sh, on the non-overlay legs.
# Also empty for `installer_source: published`, where the script under test
# is production's and has no such hook: overlaying this ref's Python onto it
# would report on neither one honestly.
UNSLOTH_CI_SOURCE_OVERLAY: ${{ matrix.overlay && inputs.installer_source != 'published' && github.workspace || '' }}
run: |
set -a; . ./clean-machine.env; set +a
set -o pipefail
rc=0
FLAGS="${{ matrix.flags }}"
case "${{ matrix.delivery }}" in
file)
# 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. 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,
# so only add the separator when there are flags to pass.
if [ -n "$FLAGS" ]; then
cat install.sh | sh -s -- $FLAGS 2>&1 | tee logs/install.log || rc=$?
else
cat install.sh | sh 2>&1 | tee logs/install.log || rc=$?
fi
fi
;;
tauri)
# Exactly how the desktop app invokes it: no tty, stdin closed.
# --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=$?
;;
esac
echo "install_rc=$rc" >> "$GITHUB_OUTPUT"
echo "installer exit code: $rc"
# 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
exit "$rc"
# Without this the gap comes back silently: install.sh ignores an unset
# UNSLOTH_CI_SOURCE_OVERLAY, so a typo in the matrix or the expression would
# put every leg back on the released wheel and nothing in the run would say so.
- name: Assert this ref's Python was really put under test
if: matrix.overlay && inputs.installer_source != 'published' && steps.install.outcome == 'success'
run: |
grep -q "CI: overlaying source checkout" logs/install.log || {
echo "::error::this leg is marked overlay: true but the installer never overlaid the checkout, so it only tested the released package"
exit 1
}
echo "overlay applied; this leg exercised this ref's Python"
- name: Assert no source build and no toolchain use
if: always() && steps.install.outcome == 'success'
run: |
set -a; . ./clean-machine.env; set +a
checks="nobuild"
# `absent` ran only BEFORE the install, so an installer that quietly
# selected the CLT or installed a compiler left the leg green while every
# later source build could succeed -- the exact behaviour the assert script
# says `absent` guards the whole run against. Re-run it after the install.
[ "${{ matrix.mode }}" = "mask" ] && checks="$checks absent"
[ "${{ matrix.mode }}" = "trace" ] && checks="$checks notools"
UNSLOTH_CLEAN_ALLOW_WORKING='${{ matrix.allow_working }}' \
bash .github/scripts/clean-machine-assert.sh $checks
- name: Assert llama.cpp loads
if: steps.install.outcome == 'success'
run: |
set -a; . ./clean-machine.env; set +a
# 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
HOME_DIR="$UNSLOTH_STUDIO_HOME"
fi
STUDIO_HOME="$HOME_DIR" bash .github/scripts/assert-llama-loads.sh
- name: Restore the runner
if: always()
run: bash .clean-machine/restore.sh || true
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
# 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/
runner-baseline.txt
clean-machine.env
.clean-machine/tool-invocations.log
retention-days: 7
if-no-files-found: warn
# ── Linux: genuinely clean, via containers ────────────────────────────────
linux:
name: linux ${{ matrix.label }}
runs-on: ${{ matrix.runner }}
container: ${{ matrix.image }}
timeout-minutes: 40
continue-on-error: ${{ matrix.experimental }}
# 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
strategy:
fail-fast: false
matrix:
include:
# Root + apt available: install.sh's _smart_apt_install should self-heal
# from a base image with no curl, git, gcc or cmake at all.
- label: ubuntu2404-root
image: ubuntu:24.04
runner: ubuntu-latest
experimental: false
overlay: true
- label: ubuntu2404-arm-root
image: ubuntu:24.04
runner: ubuntu-24.04-arm
experimental: false
overlay: true
# 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
# rather than a bare `curl: (56)`. No overlay: it never gets as far as a
# venv, so there would be nothing to overlay into.
- label: ubuntu2404-nonroot
image: ubuntu:24.04
runner: ubuntu-latest
experimental: true
overlay: false
# Non-apt: today this hard-fails at install.sh:2034. Expected failure;
# forces the decision on whether dnf/pacman/zypper get supported.
- label: fedora41
image: fedora:41
runner: ubuntu-latest
experimental: true
overlay: true
steps:
- name: Describe the container's starting state
run: |
for t in curl wget git gcc cc cmake make python3 sudo; do
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
# transport is provisioned apart from the installer's own dependencies.
# Everything else stays absent.
- name: Provision only the bootstrap transport
run: |
# tar and gzip ride along on the overlay legs: with no actions/checkout here
# (it needs git) the only way to get this ref's source into the container is
# to fetch and unpack an archive over the same transport. Neither is a
# compiler, git or cmake, so the clean-machine premise is untouched. Both
# are usually already in the base image; naming them just makes it certain.
pkgs="ca-certificates curl"
if [ "${{ matrix.overlay }}" = "true" ]; then pkgs="$pkgs tar gzip"; fi
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq --no-install-recommends $pkgs
elif command -v dnf >/dev/null 2>&1; then
dnf install -y -q $pkgs
fi
# 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
# 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)"
else
curl -fsSL "$raw/install.sh" -o install.sh
echo "installer: this ref (${GITHUB_SHA})"
fi
wc -l install.sh
# The overlay needs a source tree, and these legs deliberately have no
# actions/checkout. codeload serves the same commit as a tarball over plain
# HTTPS, so this ref's Python code gets in without a git client.
- name: Fetch this ref's source tree for the overlay
if: matrix.overlay && inputs.installer_source != 'published'
run: |
set -e
mkdir -p ci-source
curl -fsSL "https://codeload.github.com/${GITHUB_REPOSITORY}/tar.gz/${GITHUB_SHA}" \
| tar -xz -C ci-source --strip-components=1
[ -f ci-source/pyproject.toml ] || { echo "::error::source tarball for ${GITHUB_SHA} unpacked without a pyproject.toml"; ls -la ci-source; exit 1; }
echo "overlay source: $(pwd)/ci-source"
- name: Create an unprivileged user
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
# 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"
- name: Install (root)
id: install_root
if: matrix.label != 'ubuntu2404-nonroot'
run: |
set -o pipefail
# Resolved here rather than in `env:` so it tracks the step's real working
# directory: a container job remaps the workspace and github.workspace is
# not something this needs to depend on.
if [ -d ci-source ]; then
export UNSLOTH_CI_SOURCE_OVERLAY="$PWD/ci-source"
echo "overlaying this ref's source from $UNSLOTH_CI_SOURCE_OVERLAY"
fi
rc=0
# 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"
- name: Install (unprivileged, expected to fail cleanly)
if: matrix.label == 'ubuntu2404-nonroot'
run: |
set -o pipefail
rc=0
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.
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
# 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 outcome is a known one
if: always() && matrix.label == 'fedora41'
run: |
if [ "${{ steps.install_root.outcome }}" = "success" ]; then
echo "::warning::fedora install succeeded -- non-apt support may now exist; retire this leg"
exit 0
fi
[ -f logs/install.log ] || { echo "::error::fedora leg produced no install log"; exit 1; }
tail -40 logs/install.log
# install.sh comes from this ref, so which of the two accepted outcomes
# applies depends on which dependency gate this ref carries.
if grep -q "using prebuilt llama.cpp (missing:" logs/install.log; then
# The gate no longer hard-stops on a non-apt distro: it warns that the
# optional build tools are absent and carries on. Reaching this warning is
# what proves the Linux gate did not stop the install.
# Past that point the accepted failure used to be release lag: install.sh
# came from this ref but unsloth from PyPI, and the released
# studio/install_python_stack.py has no "skip the triton kernels when git
# is missing" guard, so it fetched the git+https triton_kernels
# requirement on a machine with no git. This leg is now overlaid with this
# ref's Python (see the header), so that guard is this ref's own code and
# the triton failure must NOT come back. Accepting it here would be
# accepting a regression in the guard as if it were release lag.
if grep -q "Installing triton kernels (pip) failed" logs/install.log; then
echo "::error::triton kernels still failed with this ref's install_python_stack.py overlaid, so its no-git skip did not hold"
exit 1
fi
# Nothing past the dependency warning is acceptable any more: the one
# tolerated failure was the released package lagging this ref, and the
# overlay removes that difference. A failure here is this ref's own.
echo "::error::fedora got past the dependency warning and still failed, with this ref's Python overlaid; there is no known-good outcome left to accept"
exit 1
fi
# This ref still hard-exits on a non-apt package manager. Pin that message so
# a bootstrap outage or an unrelated early exit is not tolerated as if it
# were the intentional diagnostic.
if ! grep -qiE "Automatic system package installation is supported on apt-based|Fedora/RHEL: sudo dnf install" logs/install.log; then
echo "::error::fedora leg failed neither at the unsupported-package-manager gate nor past the dependency warning"
exit 1
fi
# See the macOS job: proves the leg is testing what its matrix row claims.
- name: Assert this ref's Python was really put under test
if: matrix.overlay && inputs.installer_source != 'published' && steps.install_root.outcome == 'success'
run: |
grep -q "CI: overlaying source checkout" logs/install.log || {
echo "::error::this leg is marked overlay: true but the installer never overlaid the checkout, so it only tested the released package"
exit 1
}
echo "overlay applied; this leg exercised this ref's Python"
# nobuild only reads the log, so an installer that exits 0 having done nothing
# 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: |
VENV="$UNSLOTH_STUDIO_HOME/unsloth_studio"
[ -x "$VENV/bin/python" ] || { echo "::error::installer exited 0 but left no managed Python at $VENV/bin/python"; ls -la "$UNSLOTH_STUDIO_HOME" || true; exit 1; }
"$VENV/bin/python" -V
[ -x "$VENV/bin/unsloth" ] || { echo "::error::installer exited 0 but left no unsloth CLI at $VENV/bin/unsloth"; exit 1; }
- name: Assert no source build
if: always()
run: |
if [ -f .github/scripts/clean-machine-assert.sh ]; then
INSTALL_LOG=logs/install.log bash .github/scripts/clean-machine-assert.sh nobuild
else
echo "::warning::assert script unavailable (fetch step did not run)"
fi
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: clean-linux-${{ matrix.label }}
path: |
logs/
/tmp/container-baseline.txt
retention-days: 7
if-no-files-found: warn
# ── 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) 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 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
timeout-minutes: 50
continue-on-error: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Import a fresh Ubuntu 24.04 WSL distro
shell: pwsh
run: |
# WSL2 is present on windows-2022+ runner images; only a distro is missing.
wsl --set-default-version 2
$url = 'https://cloud-images.ubuntu.com/wsl/releases/24.04/20240423/ubuntu-noble-wsl-amd64-24.04lts.rootfs.tar.gz'
$expected = '2a790896740b14d637dbdc583cce1ba081ac53b9e9cdb46dc09a2f73abbd9934'
New-Item -ItemType Directory -Force -Path wsl-dist, logs | Out-Null
Invoke-WebRequest -Uri $url -OutFile wsl-dist/rootfs.tar.gz -UseBasicParsing -TimeoutSec 900
$actual = (Get-FileHash wsl-dist/rootfs.tar.gz -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $expected) {
Write-Host "::error::rootfs checksum mismatch: got $actual"
exit 1
}
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, 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: 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 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
# 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
$installRc = $LASTEXITCODE
Write-Host "installer exit: $installRc"
# Printing the code discarded it. The CLI check in the next step does not
# compensate: install.sh links the `unsloth` shim (4174-4182) BEFORE it
# reports a failing studio/setup.sh (4219-4230), so a late setup failure
# leaves a shim whose --version succeeds and the whole job looked green.
if ($installRc -ne 0) {
Write-Host "::error::WSL installer exited $installRc"
exit $installRc
}
- name: Did it detect WSL, and did it end up usable?
if: always()
shell: pwsh
run: |
# 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 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 |
ForEach-Object { $_ -replace "$esc\[[0-9;]*[A-Za-z]", '' } |
Where-Object { $_ -match '^\s*platform\s+\S' }
)
$platformLines | ForEach-Object { Write-Host "platform line: $_" }
if (-not ($platformLines | Where-Object { $_ -match '^\s*platform\s+wsl\s*$' })) {
Write-Host '::error::installer never reported ''platform wsl''; the WSL branch was not exercised'
exit 1
}
# No `|| echo`: substituting a message for the missing CLI made the inner
# 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
if ($verifyRc -ne 0) {
Write-Host '::error::WSL install left no usable unsloth CLI'
exit 1
}
- name: Tear the distro down
if: always()
shell: pwsh
run: wsl --unregister unsloth-ci 2>&1 | Out-Null; exit 0
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: clean-wsl-ubuntu2404
path: logs/
retention-days: 7
if-no-files-found: warn
# ── Windows ───────────────────────────────────────────────────────────────
windows:
name: win ${{ matrix.os }} / winget=${{ matrix.winget }}
runs-on: ${{ matrix.os }}
timeout-minutes: 45
continue-on-error: ${{ matrix.experimental }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
winget: 'visible'
experimental: false
overlay: true
# 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.
#
# This leg fails at studio/setup.ps1:1652-1670, the unconditional
# "Git is required but could not be installed automatically" gate: no winget
# means no way to fetch git, and setup.ps1 refuses to continue without it.
# Before the overlay that failure came out of the RELEASED setup.ps1 and said
# nothing about this ref. It now comes out of this ref's own copy, which
# still carries the same gate, so the leg is red for a real and currently
# unfixed reason rather than for an untestable one. Relaxing that gate to
# `--local` and llama.cpp source builds is what turns it green, and this
# overlay is what will let this workflow see it happen.
- os: windows-latest
winget: 'masked'
experimental: false
overlay: true
- os: windows-11-arm
winget: 'visible'
experimental: true
overlay: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# No actions/setup-python here either: install.ps1 must bootstrap Python.
- name: Simulate a clean machine
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 = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\',
'CMake', 'Microsoft Visual Studio', 'BuildTools', 'LLVM',
'MSYS', 'mingw', 'Strawberry')
# 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 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 = {
param($entries)
$out = $entries | Where-Object {
$p = $_; $p -and -not ($drop | Where-Object { $p -like "*$_*" })
}
if ('${{ matrix.winget }}' -eq 'masked') {
$out = $out | Where-Object { $_ -notlike '*WinGet*' -and $_ -notlike '*Microsoft\WindowsApps*' }
}
,@($out)
}
$kept = & $scrub ($env:PATH -split ';')
if ($wantWinget) {
if (-not $wingetCmd) {
Write-Host '::error::winget was not on PATH before scrubbing; this leg cannot test the winget path'
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)`" %*"
$kept = @($shim) + $kept
}
# Take the toolcache Python off disk, not just off PATH: py.exe lives in
# C:\Windows (which must stay) and uv does its own interpreter discovery, so
# both reach the toolcache whatever PATH says -- 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
Write-Host "masked toolcache python: $tc" }
catch { Write-Host "::error::could not mask $tc ($($_.Exception.Message)); the leg would not be clean"; exit 1 }
}
}
$newPath = ($kept -join ';')
"PATH=$newPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# 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 }
$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 simulation 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
}
Write-Host "kept PATH entries: $($kept.Count)"
- name: Verify the simulation took effect
shell: pwsh
run: |
$leaked = @()
# `py` too: the launcher lives in C:\Windows, which the scrub keeps, and it
# 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)" }
}
# The launcher binary may stay, but an interpreter it can still START is a
# leak: Find-CompatiblePython (install.ps1:1130-1153) probes `py` first, so
# any version registered outside the two renamed toolcache directories gets
# reused and Python bootstrap never runs. Exempting `py` without running it
# left that unchecked.
if (Get-Command py -ErrorAction SilentlyContinue) {
# -0p prints the launcher's REGISTRY view. The mask step renames the
# toolcache directory on disk but cannot rewrite those registry entries,
# so -0p keeps naming paths that no longer exist. It is context for a
# failure, never evidence of one -- only a probe that starts counts.
Write-Host "py -0p (stale registry entries; masked paths no longer exist on disk):"
& py -0p 2>&1 | ForEach-Object { Write-Host " $_" }
foreach ($v in '-3.11', '-3.12', '-3.13') {
$out = & py $v -c "import sys; print(sys.executable)" 2>&1
$rc = $LASTEXITCODE
# Print every probe: when this check next fails it must say why.
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 probe that FAILS is the outcome we want, but it leaves $LASTEXITCODE
# non-zero, and Get-Command/Write-Host are cmdlets that never reset it.
# The runner appends
# if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }
# to every pwsh step (actions/runner#351), so all three Windows legs
# exited 1 with no ::error:: printed, on machines that were in fact clean
# -- and never reached the Install step at all.
$global:LASTEXITCODE = 0
}
# 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
}
$winget = Get-Command winget -ErrorAction SilentlyContinue
Write-Host ("winget {0}" -f $(if ($winget) { $winget.Source } else { 'ABSENT' }))
if ('${{ matrix.winget }}' -eq 'masked') {
if ($winget) {
Write-Host '::error::winget still resolvable; masking failed'
exit 1
}
} elseif ('${{ matrix.os }}' -eq 'windows-latest' -and -not $winget) {
# 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
}
foreach ($scope in 'Machine','User') {
Write-Host ("{0} PATH after scrub: {1}" -f $scope, [System.Environment]::GetEnvironmentVariable('Path', $scope))
}
# Every failure above exits 1 explicitly, so reaching here means the machine
# is clean. Be explicit rather than leaving the runner's appended
# `exit $LASTEXITCODE` to decide.
exit 0
- name: Install
id: install
shell: pwsh
env:
# Empty, and therefore ignored by install.ps1, on the non-overlay legs.
UNSLOTH_CI_SOURCE_OVERLAY: ${{ matrix.overlay && inputs.installer_source != 'published' && github.workspace || '' }}
run: |
$ErrorActionPreference = 'Continue'
# No -SkipTorch: install.ps1 has no param block and its parser matches
# `--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"
exit $rc
# See the macOS job: proves the leg is testing what its matrix row claims.
- name: Assert this ref's Python was really put under test
if: matrix.overlay && inputs.installer_source != 'published' && steps.install.outcome == 'success'
shell: pwsh
run: |
if (-not (Select-String -Path logs/install.log -Pattern 'CI: overlaying source checkout' -SimpleMatch -Quiet)) {
Write-Host '::error::this leg is marked overlay: true but the installer never overlaid the checkout, so it only tested the released package'
exit 1
}
Write-Host "overlay applied; this leg exercised this ref's Python"
- name: Assert torch loads, and record what that does and does not prove
if: steps.install.outcome == 'success'
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 `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'
if (-not (Test-Path $py)) { $py = (Get-Command python -ErrorAction SilentlyContinue).Source }
if (-not $py) { Write-Host '::error::no python from the install'; exit 1 }
& $py -c "import ctypes.util, sys; print('VCRUNTIME140:', ctypes.util.find_library('vcruntime140'))"
& $py -c "import torch; print('torch', torch.__version__)"
if ($LASTEXITCODE -ne 0) { Write-Host '::error::torch failed to import (VC++ runtime missing?)'; exit 1 }
if ('${{ matrix.winget }}' -eq 'masked') {
# install.ps1:1098, the no-winget branch of the winget check.
$noWinget = 'will require Python + uv to be already installed'
if (-not (Select-String -Path logs/install.log -Pattern $noWinget -SimpleMatch -Quiet)) {
Write-Host '::error::masked leg never reported winget as unavailable; it did not take the no-winget path'
exit 1
}
}
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: clean-win-${{ matrix.os }}-${{ matrix.winget }}
path: logs/
retention-days: 7
if-no-files-found: warn