unsloth/.github/workflows/clean-machine-install-ci.yml
danielhanchen f09a1e71a9 Tighten the clean-machine CI comments
Comments only, no assertion logic, pins or leg definitions touched.

Reflowed every rationale block to denser wording and removed the
duplication that had built up across repeated steps: the desktop
workflow repeated the fork-PR skip, the desktop-v* tag resolution and
the restore-runner note once per platform, and the installer workflow
repeated its path-filter rationale in both the pull_request and push
blocks. Those now point at the first copy.

Every WHY is kept: why the masked legs avoid install.sh --local, what
UNSLOTH_CI_SOURCE_OVERLAY is for, why `absent` tests "must not work"
rather than command -v, why the .venv_t5_* sidecars are in the macho
scan scope, why the signature check is main-executables-only, why each
nobuild allowlist entry is a pure-Python sdist, why the WSL job gates
and what the pipe truncation was, and why the virgin container's
overlay=false row is still pinned.

Proved comments-only three ways: both workflow revisions parsed with
yaml.safe_load_all and every leaf walked (only `run:` scalars differ);
every changed bash body and .sh compared byte-for-byte after
`bash --pretty-print -n`; every changed pwsh body and .ps1 compared as
a token stream with Comment and NewLine tokens dropped. A negative
control injecting one non-comment line into each layer makes all of
them fail.
2026-07-29 06:33:16 +00:00

1728 lines
94 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: 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 ways (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 `overlay` decides ────────────────────────────────────────────────────
# install.sh / install.ps1 come from this ref but install unsloth FROM PyPI, the
# consumer path, which has to stay that way -- so everything Python-side would be the
# RELEASED wheel's (setup.sh, setup.ps1, install_python_stack.py and every
# requirements/constraints file they reach via Path(__file__)) and a branch changing
# any of them would get a green run proving nothing. `overlay: true` legs therefore
# re-point the venv at this ref before studio setup, via UNSLOTH_CI_SOURCE_OVERLAY: a
# `--no-deps` editable install of the checkout, so `import studio` resolves to the
# working tree and the setup-script lookup finds this ref's setup.sh / setup.ps1. NOT
# `install.sh --local`, which also pulls `unsloth-zoo @ git+https://...` and so needs
# the git these legs remove; an editable overlay resolves and clones nothing.
#
# Legs left on `overlay: false`:
# 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 calls `git rev-parse` / `git archive` itself via
# setuptools-scm's file finder, answering the leg's own question.
# linux ubuntu2404-nonroot-notransport dies at the elevation gate before a venv exists.
# wsl only install.sh is copied in; no source tree inside WSL.
name: Clean machine install
on:
pull_request:
paths:
- 'install.sh'
- 'install.ps1'
- 'studio/setup.sh'
- 'studio/setup.ps1'
- 'studio/install_python_stack.py'
# setup.sh (727) and setup.ps1 (2343, 3630, 3916) call these directly and the
# overlay makes them THIS ref's code, so they decide whether a clean machine gets
# a native prebuilt or a toolchain-dependent fallback.
- 'studio/install_*_prebuilt.py'
- 'studio/prebuilt_core.py'
- 'studio/node_prebuilt_pins.json'
# The overlay exists so a constraints or requirements change is exercised here
# (see the header). update-smoke cannot stand in: it starts from a preinstalled
# Python and full developer tooling.
- 'studio/backend/requirements/**'
- '.github/scripts/clean-machine-*.sh'
- '.github/scripts/assert-llama-loads.sh'
# The virgin Windows container lane lives in this workflow too.
- '.github/scripts/virgin-windows-*.ps1'
- '.github/scripts/ensure-docker-daemon.ps1'
- '.github/scripts/assert-nobuild.ps1'
- '.github/workflows/clean-machine-install-ci.yml'
push:
branches: [main]
# Same list as the PR filter: without it a direct push to main touching any of
# these skipped the workflow and the post-merge backstop never happened.
paths:
- 'install.sh'
- 'install.ps1'
- 'studio/setup.sh'
- 'studio/setup.ps1'
- 'studio/install_python_stack.py'
- 'studio/install_*_prebuilt.py'
- 'studio/prebuilt_core.py'
- 'studio/node_prebuilt_pins.json'
- 'studio/backend/requirements/**'
- '.github/scripts/clean-machine-*.sh'
- '.github/scripts/assert-llama-loads.sh'
- '.github/scripts/virgin-windows-*.ps1'
- '.github/scripts/ensure-docker-daemon.ps1'
- '.github/scripts/assert-nobuild.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 `nobuild` can only 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 is 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, with torch because that is
# what a consumer 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), planting the
# very evidence `notools` looks 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 rather than let 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, 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)"
# Neither is documented for these images and both change what a binary is
# allowed to do. One line settles it for anyone reading the artifact.
echo "spctl --status : $(spctl --status 2>&1 || true)"
echo "csrutil status : $(csrutil status 2>&1 || true)"
} | 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 looks
# exactly like an installer that touched nothing and the one leg carrying that
# assertion would pass whatever the installer did. Prove the wrapper records
# before trusting an empty file. macOS never probes git off the --local path,
# so the call has to be explicit.
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 so ignored by install.sh, on the non-overlay legs. Empty for
# `installer_source: published` too: the script under test is then
# production's, which has no such hook, and overlaying this ref's Python onto
# it would report on neither 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 }}"
# A consumer has no CI=true, no GITHUB_*, no RUNNER_*: branching on any of them
# is a hidden dependency nobody outside CI exercises. Scoped to the installer's
# own process, so $GITHUB_OUTPUT below still resolves. `case` rather than
# `sed`, whose BRE has no \| alternation on macOS.
CLEAN_ENV=""
for v in $(env | cut -d= -f1); do
case "$v" in CI|GITHUB_*|RUNNER_*) CLEAN_ENV="$CLEAN_ENV -u $v" ;; esac
done
echo "unset for the installer:$CLEAN_ENV"
# A `published` dispatch asks whether unsloth.ai's script works, and only
# `pipe` honoured it, so six of eight macOS rows ran the checked-out script
# under the published label. Resolved once here for every delivery. Empty on
# pull_request/push, so automatic runs stay on this ref.
SCRIPT=install.sh
if [ "${{ inputs.installer_source }}" = "published" ]; then
curl -fsSL https://unsloth.ai/install.sh -o published-install.sh
SCRIPT=published-install.sh
echo "installer: published (unsloth.ai)"
else
echo "installer: this ref ($GITHUB_SHA)"
fi
case "${{ matrix.delivery }}" in
file)
# Isolates "installer logic broken" from "curl-pipe delivery broken".
env $CLEAN_ENV bash "$SCRIPT" $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. The published case re-fetches
# rather than piping $SCRIPT: the live transport is half of what this
# delivery tests.
if [ "${{ inputs.installer_source }}" = "published" ]; then
curl -fsSL https://unsloth.ai/install.sh | env $CLEAN_ENV sh -s -- $FLAGS 2>&1 | tee logs/install.log || rc=$?
else
# `sh -s --` with no further args passes an empty positional, so add
# the separator only when there are flags.
if [ -n "$FLAGS" ]; then
cat install.sh | env $CLEAN_ENV sh -s -- $FLAGS 2>&1 | tee logs/install.log || rc=$?
else
cat install.sh | env $CLEAN_ENV 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.
env -u UNSLOTH_STUDIO_HOME $CLEAN_ENV \
bash "$SCRIPT" --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"
# install.sh ignores an unset UNSLOTH_CI_SOURCE_OVERLAY, so a typo in the matrix or
# the expression silently puts every leg back on the released wheel.
- 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. Re-run it after.
[ "${{ 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, and every downloaded Mach-O is native and signed
if: steps.install.outcome == 'success'
run: |
set -a; . ./clean-machine.env; set +a
# The tauri leg cannot honour UNSLOTH_STUDIO_HOME (see Install) and went to the
# legacy root, where llama.cpp sits at <root>/llama.cpp and the venv at
# <root>/studio: so ~/.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
# Rosetta 2 is on this runner and not on a fresh Mac, so llama-server launching
# above does not prove it would launch for a user. Assert the arch of every
# payload (llama.cpp, whisper.cpp, the Node prebuilt, uv) instead.
MACHO_ROOT="$HOME_DIR" bash .github/scripts/clean-machine-assert.sh macho
- name: Restore the runner
if: always()
# `|| true` swallowed everything, a genuinely broken restore included. The file
# only exists once the strip step ran and an earlier step can fail before that,
# so skip explicitly when it is absent and let a real failure surface.
run: |
if [ -f .clean-machine/restore.sh ]; then
bash .clean-machine/restore.sh
else
echo "no .clean-machine/restore.sh: the toolchain was never stripped, nothing to restore"
fi
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
# Two 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 starts.
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
include:
# Root + apt: 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, but WITH the transport the advertised one-liner needs: not
# root, no sudo anywhere on the image, no toolchain, ca-certificates + curl and
# nothing else. Since #7547 the optional set (cmake, git, build-essential,
# libcurl4-openssl-dev) never escalates, so this must install end to end off
# prebuilt llama.cpp, and until now nothing proved it.
#
# Gating, and overlay: true for the reason fedora41 is: the RELEASED
# install_python_stack.py has no "skip triton kernels when git is missing"
# guard, so without the overlay the run reaches the final step and dies there
# on pure release lag (staging run 30421021166: venv, frontend, torch and
# extras all fine, then `Installing triton kernels (pip) failed`).
- label: ubuntu2404-nonroot
image: ubuntu:24.04
runner: ubuntu-latest
experimental: false
overlay: true
nonroot: true
# The same premise with the OTHER transport. install.sh's download() prefers
# curl and falls back to wget (729-738), _http_get does the same for the
# connectivity probe (1019-1027), the Radeon listing repeats it (3064-3067),
# and _check_linux_deps calls the transport missing only when BOTH are gone
# (2077-2079). So a box with wget and no curl -- a Debian netinst default, and
# every image where curl was deliberately removed -- is supported on paper and
# had never been run: the nonroot row above provisions ca-certificates AND
# curl, so curl won every probe and the wget branch was only ever reasoned from
# the code. This row asserts everything that one does, plus curl proved absent
# for the whole run rather than merely unused.
- label: ubuntu2404-nonroot-wget
image: ubuntu:24.04
runner: ubuntu-latest
experimental: false
overlay: true
nonroot: true
wget_only: true
# No elevation AND no transport. apt is the only way to get curl and reaching
# apt needs elevation, so failing is correct; the point is to pin the exact
# message and prove it is actionable rather than a bare `curl: (56)`. No
# overlay: it never reaches a venv to overlay into.
- label: ubuntu2404-nonroot-notransport
image: ubuntu:24.04
runner: ubuntu-latest
experimental: false
overlay: false
nonroot: true
no_transport: true
# Non-apt: today this hard-fails at install.sh:2034. Expected; 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 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 in for this ref's source is an archive over the
# same transport. Neither is a compiler, git or cmake, so the premise holds;
# both are usually in the base image already, naming them makes it certain.
#
# One transport, never both: the wget-only row tests install.sh's wget branch,
# which is unreachable while curl is on the box.
if [ "${{ matrix.wget_only }}" = "true" ]; then
pkgs="ca-certificates wget"
else
pkgs="ca-certificates curl"
fi
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, INSTALLER included, so these legs validate a fix and not just 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}"
# Whichever transport this row provisioned: the wget-only leg has no curl, on
# purpose, and this is the one download in the job that cannot go through
# install.sh's own helper. Same preference order as it uses.
dl() {
if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2"
else wget -q -O "$2" "$1"; fi
}
dl "$raw/.github/scripts/clean-machine-assert.sh" .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
dl https://unsloth.ai/install.sh install.sh
echo "installer: published (unsloth.ai)"
else
dl "$raw/install.sh" 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 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
src="https://codeload.github.com/${GITHUB_REPOSITORY}/tar.gz/${GITHUB_SHA}"
# See the step above: the wget-only leg has to fetch with wget.
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$src" | tar -xz -C ci-source --strip-components=1
else
wget -q -O - "$src" | tar -xz -C ci-source --strip-components=1
fi
[ -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"
# curl is what fetched install.sh above, so the no-transport leg cannot simply
# never install it. Take it away again afterwards: from the installer's point of
# view the machine can download nothing, which is the case under test.
- name: Take the transport away again
if: matrix.no_transport
run: |
apt-get remove -y -qq curl >/dev/null
for t in curl wget; do
if command -v "$t" >/dev/null 2>&1; then
echo "::error::$t is still resolvable, so this leg is not the no-transport case"
exit 1
fi
done
echo "no curl and no wget: the installer has no transport"
- name: Create an unprivileged user
if: matrix.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 (536-591), long before the
# elevation gate (829-905). Without a writable target these legs die on "cannot
# be created" rather than on anything they are asking about.
mkdir -p "$UNSLOTH_STUDIO_HOME"
chown -R tester logs install.sh "$UNSLOTH_STUDIO_HOME"
# The editable overlay writes .egg-info next to the pyproject.toml, so the
# source tree has to belong to tester too or the overlay fails on permissions
# rather than on anything this leg asks about.
if [ -d ci-source ]; then chown -R tester ci-source; fi
# Not calling sudo is not the same as not having it: a leg that merely avoided the
# call would pass on an image where elevation was available all along, and the
# whole claim of these two rows is that there is none to be had.
- name: Prove the unprivileged user genuinely cannot elevate
if: matrix.nonroot
run: |
uid="$(su tester -c 'id -u')"
echo "tester uid: $uid"
if [ "$uid" = "0" ]; then
echo "::error::tester resolved to uid 0, so this leg is not unprivileged"
exit 1
fi
# Absent from disk, not merely off PATH: install.sh probes with `command -v`
# (830), so a binary tester could not reach would still be a lie about the
# image.
for p in /usr/bin/sudo /bin/sudo /usr/local/bin/sudo /usr/sbin/sudo /sbin/sudo; do
if [ -e "$p" ]; then
echo "::error::$p exists, so this image is not sudo-free"
exit 1
fi
done
if su tester -c 'command -v sudo' >/dev/null 2>&1; then
echo "::error::sudo resolves for tester; the no-elevation premise does not hold"
exit 1
fi
# The capability, not just the tool: the escalation install.sh would attempt
# writes the dpkg database, so an unwritable one is what makes `apt-get
# install` impossible for tester.
if su tester -c 'test -w /var/lib/dpkg/status'; then
echo "::error::tester can write the dpkg database, so it is effectively root"
exit 1
fi
echo "tester is unprivileged, has no sudo on disk, and cannot write dpkg state"
# The mirror of the sudo proof above, for the other premise this row makes. Not
# calling curl is not the same as not having it: every transport site in install.sh
# probes with `command -v curl` and prefers it (731, 1022, 2078, 3064), so a leg
# that merely avoided the call would go on testing the curl branch and report the
# wget one green.
- name: Prove wget is the only transport
if: matrix.wget_only
run: |
# On disk, not merely off PATH, for the same reason the sudo check is:
# `command -v` is what install.sh asks, and a binary tester could not reach
# would still be a lie about the image.
for p in /usr/bin/curl /bin/curl /usr/local/bin/curl /usr/sbin/curl /sbin/curl /snap/bin/curl; do
if [ -e "$p" ]; then
echo "::error::$p exists, so this leg is not wget-only"
exit 1
fi
done
for u in root tester; do
if su "$u" -c 'command -v curl' >/dev/null 2>&1; then
echo "::error::curl resolves for $u; the wget-only premise does not hold"
exit 1
fi
done
# The package too, so a dependency that quietly pulled the binary back in is
# caught rather than silently reinstating the curl branch.
if dpkg-query -W -f='${Status}' curl 2>/dev/null | grep -q 'install ok installed'; then
echo "::error::the curl package is installed; the wget-only premise does not hold"
exit 1
fi
# And tester really has the other one, or the row is the no-transport case
# wearing a different label.
su tester -c 'command -v wget' >/dev/null 2>&1 || {
echo "::error::tester cannot reach wget, so this leg has no transport at all"
exit 1
}
echo "wget only: $(su tester -c 'wget --version' | head -1)"
- name: Install (root)
id: install_root
if: ${{ !matrix.nonroot }}
run: |
set -o pipefail
# Resolved here, not in `env:`, so it tracks the step's real working directory:
# a container job remaps the workspace, and this need not depend on
# github.workspace.
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"
# The no-elevation case the workflow was missing: everything absent AND no way to
# become root, but the transport the documented one-liner needs is there. Gating,
# and asserted end to end by the same steps the root legs use.
- name: Install (unprivileged, no sudo)
id: install_nonroot
if: ${{ matrix.nonroot && !matrix.no_transport }}
run: |
set -o pipefail
# su without a login shell keeps the environment, so this reaches tester.
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, like the root legs: the advertised command, and the shape that turns
# an early exit into curl:(56).
su tester -c 'cat install.sh | sh' 2>&1 | tee logs/install.log || rc=$?
echo "installer exit code: $rc"
exit "$rc"
- name: Install (unprivileged and no transport, expected to fail cleanly)
id: install_notransport
if: matrix.no_transport
continue-on-error: true
run: |
set -o pipefail
rc=0
su tester -c 'cat install.sh | sh' 2>&1 | tee logs/install.log || rc=$?
echo "installer exit code: $rc"
exit "$rc"
# KNOWN OUTCOME PIN. This row is required, and continue-on-error on the step above
# would otherwise tolerate a bootstrap outage or an unrelated early exit exactly
# like the intended diagnostic, so every branch here but the pinned outcome exits 1.
- name: Assert the no-transport outcome is the elevation gate
if: always() && matrix.no_transport && steps.install_notransport.outcome != 'skipped'
run: |
if [ "${{ steps.install_notransport.outcome }}" = "success" ]; then
echo "::error::the installer completed with no transport and no way to elevate; that outcome is new, so this pin is stale"
exit 1
fi
[ -f logs/install.log ] || { echo "::error::the no-transport leg produced no install log"; exit 1; }
tail -40 logs/install.log
# Both, so a failure anywhere else is still red: it must be the transport that
# was missing, and the no-sudo branch of _smart_apt_install (install.sh:899-903)
# that stopped it, not a prompt, a dpkg lock or a network error.
if ! grep -q "missing: curl" logs/install.log; then
echo "::error::the installer never reported the transport as missing; it did not reach the elevation gate"
exit 1
fi
if ! grep -q "sudo is not available on this system" logs/install.log; then
echo "::error::the installer did not stop at the no-sudo branch of the apt helper; this is a new failure"
exit 1
fi
# Actionable, not a bare `curl: (56)`: the message has to say what to run.
if ! grep -q "apt-get install -y curl" logs/install.log; then
echo "::error::the elevation gate did not print the command a user should run"
exit 1
fi
echo "::notice::known outcome: no transport and no way to elevate, refused with an actionable message"
# experimental, so without this a bootstrap outage or an unrelated early exit is
# tolerated like the intended 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, and reaching that warning
# is what proves the Linux gate did not stop the install. Past it, the
# accepted failure used to be release lag: install.sh came from this ref but
# unsloth from PyPI, and the released install_python_stack.py has no "skip
# the triton kernels when git is missing" guard, so it fetched the git+https
# triton_kernels requirement with no git. The overlay makes that guard this
# ref's own code, so the triton failure must NOT come back: accepting it
# would be accepting a regression in the guard as 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.
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 the intended
# 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 tests 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' || steps.install_nonroot.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. Unlike WSL and Windows, these required Linux rows had no check that
# the install produced anything runnable.
- name: Assert the install is actually usable
if: steps.install_root.outcome == 'success' || steps.install_nonroot.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 llama.cpp came from the prebuilt bundle
if: steps.install_root.outcome == 'success' || steps.install_nonroot.outcome == 'success'
run: |
# HONESTY NOTE: the ROOT legs START toolchain-free but do not stay that way. As
# root, _smart_apt_install's first `apt-get install` (install.sh:797-799)
# succeeds before the _SMART_APT_OPTIONAL guard (814-821) can suppress
# anything, so `cmake git build-essential libcurl4-openssl-dev` really are
# installed mid-run: product behaviour on any root Linux install, not a CI
# artefact. What must still hold is that nothing USED them. `nobuild` reads
# Python builds only, and llama.cpp is the one thing that silently falls back
# to a source compile once a compiler is around. The unprivileged leg never
# gets that far -- the optional set cannot escalate -- so there the same marker
# proves the prebuilt path won with no compiler on the machine at all.
for t in cmake git gcc; do
printf '%-6s %s\n' "$t" "$(command -v "$t" 2>/dev/null || echo ABSENT)"
done
# install_llama_prebuilt.py:5629 writes this marker and a source-built tree has
# no such metadata (studio/setup.sh:1517), so its presence is the one
# unambiguous "the prebuilt path won" signal.
META="$UNSLOTH_STUDIO_HOME/llama.cpp/UNSLOTH_PREBUILT_INFO.json"
if [ ! -f "$META" ]; then
echo "::error::llama.cpp carries no prebuilt metadata at $META, so it did not come from the prebuilt bundle; with the compiler installed above, that is the silent source build this workflow exists to rule out"
ls -la "$UNSLOTH_STUDIO_HOME/llama.cpp" 2>/dev/null || true
grep -nE "llama\.cpp|prebuilt" logs/install.log | tail -30 || true
exit 1
fi
echo "llama.cpp came from the prebuilt bundle:"
head -c 800 "$META"; echo
# The claim this leg exists to make: a user with no elevation gets a full install
# and the machine is no less clean afterwards. Without it the row proves only that
# SOME install happened, which the root legs already show.
- name: Assert the unprivileged install elevated nothing
if: steps.install_nonroot.outcome == 'success'
run: |
left=""
for t in sudo cmake git gcc; do
p="$(command -v "$t" 2>/dev/null || echo ABSENT)"
printf '%-6s %s\n' "$t" "$p"
[ "$p" = ABSENT ] || left="$left $t"
done
if [ -n "$left" ]; then
echo "::error::the unprivileged install put system packages on the machine:$left, so something escalated"
exit 1
fi
# And it took the no-toolchain path knowingly rather than by accident.
if ! grep -q "using prebuilt llama.cpp (missing:" logs/install.log; then
echo "::error::the installer never reported the optional build tools as missing; it did not take the no-toolchain path"
grep -n "deps" logs/install.log | tail -20 || true
exit 1
fi
# #7547 made the optional set stop escalating. If either prompt comes back, an
# unprivileged user is blocked on tools nothing here uses.
if grep -qE "We require sudo elevated permissions|No terminal to confirm on" logs/install.log; then
echo "::error::the installer tried to elevate for the optional build tools; #7547's no-escalation guard has regressed"
exit 1
fi
# Absent at the start is not absent throughout, and only the whole-run claim makes
# the leg mean anything: every download the installer just did, the uv bootstrap
# included (install.sh:2232), had to go through wget, and it did only if curl was
# never there to be preferred.
- name: Re-prove curl never appeared, and that wget carried the install
if: matrix.wget_only && steps.install_nonroot.outcome == 'success'
run: |
for p in /usr/bin/curl /bin/curl /usr/local/bin/curl /usr/sbin/curl /sbin/curl /snap/bin/curl; do
if [ -e "$p" ]; then
echo "::error::$p appeared during the install, so the run did not stay wget-only"
exit 1
fi
done
if command -v curl >/dev/null 2>&1; then
echo "::error::curl resolves after the install, so the run did not stay wget-only"
exit 1
fi
# _check_linux_deps (install.sh:2077-2079) calls the transport missing only when
# curl AND wget are both gone, and the elevation gate below it is what the
# notransport row pins. Reaching it here would mean wget was not recognised as a
# transport at all.
if grep -q "missing: curl" logs/install.log; then
echo "::error::install.sh reported the transport as missing on a box that has wget, so it does not accept wget as one"
exit 1
fi
echo "curl absent before and after; every download went through wget"
- 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 mocks the environment, which cannot catch
# anything about a real WSL. No third-party action either -- the official Ubuntu rootfs
# plus `wsl --import` is deterministic and checksum-verifiable.
#
# Gating, deliberately: it is the only job that runs the real WSL branch and the only
# one that can catch a piped install being truncated (WSL shells out to Windows interop
# mid-script, and interop relays the stdin it inherited). No flake to absorb, and #7548
# is in main, so this gates unconditionally.
wsl:
name: wsl ubuntu-24.04
runs-on: windows-latest
timeout-minutes: 50
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. The
# clean machine, not a simulation.
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
# A dispatch selecting `published` asks whether unsloth.ai's script works, and
# running the checked-out one answered a different question under the same
# name. Empty on pull_request/push, so automatic runs stay on this ref.
if ('${{ inputs.installer_source }}' -eq 'published') {
Write-Host 'installer: published (unsloth.ai)'
wsl -d unsloth-ci -u root -- sh -c 'curl -fsSL https://unsloth.ai/install.sh -o /root/install.sh'
if ($LASTEXITCODE -ne 0) {
Write-Host '::error::could not fetch the published installer inside WSL'
exit 1
}
} else {
# Copy the script in rather than reach 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 on the script this dispatch selected.
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"
if (-not (Test-Path logs/wsl-install.log)) {
Write-Host '::error::the WSL install produced no log'
exit 1
}
# The pipe-integrity check. Interop (_maybe_reroute_strixhalo_to_2404 ->
# powershell.exe, wsl.exe) relays the stdin it inherited, so before #7548 it
# drank the rest of the piped script and sh died on a half-read line. #7548's
# _unsloth_main wrapper forces sh to parse the file in full first; a truncation
# here means that regressed.
if (Select-String -Path logs/wsl-install.log `
-Pattern 'Syntax error: Unterminated quoted string' -Quiet) {
Write-Host '::error::the piped install was truncated again; install.sh is no longer parsed in full before it runs'
exit 1
}
# Printing the code discarded it, and the next step's CLI check cannot
# 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.
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`,
# so a detection regression would pass as a plain-Linux install. `step` writes
# the label in reverse video, so strip ANSI or an anchored match never hits.
$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 on an install that produced nothing.
$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.
#
# It used to stop at studio/setup.ps1's unconditional "Git is required but could
# not be installed automatically" gate -- no winget meant no way to fetch git --
# and was a pinned known failure until #7549 relaxed that gate to the --local
# and llama.cpp source paths that actually use git (setup.ps1:1750-1759). The
# row now installs end to end and gates like any other; the assert below proves
# it took the relaxed branch rather than passing on git leaking back onto PATH.
- os: windows-latest
winget: 'masked'
experimental: false
overlay: true
# Windows on ARM gets a native ARM64 CPython, and torchaudio has never published
# a win_arm64 wheel at any version (nor have pyarrow and hf-transfer, which
# datasets pulls in), so the PyTorch step could not resolve and install.ps1
# stopped at "Failed to install PyTorch". Pinned until #7549, which makes the
# installer prefer an x64 interpreter on an ARM64 host and bootstrap one when
# only ARM64 is installed (install.ps1:1160-1253, 1335-1353); x64 wheels run
# fine emulated. The row now gates, and the assert below checks the outcome that
# fix has to produce rather than the log line announcing it.
- os: windows-11-arm
winget: 'visible'
experimental: false
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, so the
# blanket drop removed it on EVERY leg and winget=visible silently ran the same
# fallback as winget=masked. Resolve it before the scrub and hand it back via a
# shim, so the visible leg gets winget without the Store's python.exe alias.
# 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. That is how a leg printing
# `python ABSENT` still installed 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) merges the Machine and User registry PATHs back into $env:Path, so
# a process-only scrub lasts until the first bootstrap refresh, after which
# Git/CMake/VS/LLVM are back and the rest of the install is not clean. The
# runner is ephemeral, so rewrite the registry copies too. A merge, not a
# replace, so the shim above keeps resolving. 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 finds
# the toolcache Python 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 a version
# registered outside the renamed toolcache dirs gets reused and the Python
# bootstrap never runs.
if (Get-Command py -ErrorAction SilentlyContinue) {
# -0p is the launcher's REGISTRY view, and the mask renames directories
# without rewriting it, so -0p keeps naming paths that no longer exist:
# context for a failure, never evidence. 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 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 FAILING probe is the outcome we want, but it leaves $LASTEXITCODE
# non-zero, cmdlets never reset it, and the runner appends `exit
# $LASTEXITCODE` to every pwsh step (actions/runner#351) -- so all three legs
# exited 1, silently, on machines that were in fact clean.
$global:LASTEXITCODE = 0
}
# Printing alone could not fail: run 30365014702 logged `python ABSENT` then
# `Python 3.13 already installed` / `Using CPython ... C:\hostedtoolcache\...`.
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) {
# Or the visible leg quietly degrades into a second masked leg.
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 clean. Say so
# rather than let the runner's appended `exit $LASTEXITCODE` 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'
# Windows ships its own published script (install.ps1:3), so `published` means
# something here too: running the checked-out one regardless made a dispatch
# asking about unsloth.ai report on this ref. Empty on pull_request/push, so
# automatic runs stay on this ref.
$script = './install.ps1'
if ('${{ inputs.installer_source }}' -eq 'published') {
Invoke-WebRequest -Uri https://unsloth.ai/install.ps1 `
-OutFile published-install.ps1 -UseBasicParsing -TimeoutSec 300
$script = './published-install.ps1'
Write-Host 'installer: published (unsloth.ai)'
} else {
Write-Host "installer: this ref ($env:GITHUB_SHA)"
}
# No -SkipTorch: install.ps1's parser matches `--no-torch` only (112-142), so the
# token was silently dropped and every leg installed torch anyway. The assert
# below needs torch, so get it on purpose. Under powershell.exe, not this pwsh 7
# step: a clean Windows box ships Windows PowerShell 5.1 only, and the desktop
# launches it the same way (install.rs:325-339).
& powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass `
-File $script *>&1 | Tee-Object -FilePath logs/install.log
$rc = $LASTEXITCODE
Write-Host "installer exit code: $rc"
exit $rc
# Windows asserted nothing about the install ITSELF: nobuild and the toolchain check
# only read the log, so an installer that exited 0 having produced nothing satisfied
# both. The Linux legs have had this since they stopped being pinned; these rows
# needed it more, two of them being only just off a pin.
- name: Assert the install is actually usable
shell: pwsh
run: |
$venv = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio'
$py = Join-Path $venv 'Scripts\python.exe'
if (-not (Test-Path -LiteralPath $py)) {
Write-Host "::error::installer exited 0 but left no managed Python at $py"
Get-ChildItem -LiteralPath $env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue |
Format-Table | Out-String | Write-Host
exit 1
}
& $py -V
$cli = Join-Path $venv 'Scripts\unsloth.exe'
if (-not (Test-Path -LiteralPath $cli)) {
Write-Host "::error::installer exited 0 but left no unsloth CLI at $cli"
exit 1
}
# Present is not runnable: the console script imports the whole command tree, so
# a missing dependency or an unimportable extension surfaces here and nowhere
# else. --version is the one subcommand-free path.
& $cli --version
if ($LASTEXITCODE -ne 0) {
Write-Host '::error::the unsloth CLI is on disk but does not run'
exit 1
}
# What #7549 has to produce on this host, checked as an outcome rather than the log
# line announcing it. torchaudio, pyarrow and hf-transfer publish no win_arm64 wheel
# at any version, so a native ARM64 interpreter cannot resolve the stack; the
# installer's answer is to prefer, and if necessary bootstrap, an x64 CPython and
# let it run emulated. Asked of the interpreter through sysconfig, not inferred from
# PROCESSOR_ARCHITECTURE, which describes the shell rather than the venv.
- name: Assert the ARM64 host installed against an x64 interpreter
if: matrix.os == 'windows-11-arm'
shell: pwsh
run: |
$venvPy = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\python.exe'
$tag = (& $venvPy -c "import sysconfig; print(sysconfig.get_platform())" 2>&1 | Out-String).Trim()
$global:LASTEXITCODE = 0
Write-Host "venv interpreter platform: $tag"
if ($tag -ne 'win-amd64') {
Write-Host "::error::the venv was built from a '$tag' interpreter, so the x64 preference on ARM64 hosts has regressed and the missing win_arm64 wheels are back"
exit 1
}
# The package that has never shipped a win_arm64 wheel, so its presence proves
# the emulated x64 stack really resolved rather than being skipped. Metadata,
# not an import: this asserts resolution, the torch assert below does the import.
& $venvPy -c "from importlib.metadata import version; print('torchaudio', version('torchaudio'))"
if ($LASTEXITCODE -ne 0) {
Write-Host '::error::torchaudio is not installed, so the x64 interpreter did not buy the wheels it was chosen for'
exit 1
}
# The other half of what #7549 has to produce. This row is the only place the relaxed
# git gate matters: winget is masked, so there is no way to fetch git at all, and
# setup.ps1 used to refuse to continue without it. Assert the relaxed branch was
# taken, so the row cannot go green on git leaking back onto PATH with the gate
# never reached.
- name: Assert the no-winget path installed without git
if: matrix.winget == 'masked'
shell: pwsh
run: |
$log = Get-Content logs/install.log -Raw
if ($log -match 'Git is required but could not be installed automatically') {
Write-Host '::error::setup.ps1 stopped at the unconditional git gate; #7549 relaxed it to --local and source-build installs, so that has regressed'
exit 1
}
if (-not ($log -match 'Git not found -- attempting install via winget')) {
Write-Host '::error::setup.ps1 found git on this machine, so the scrub leaked it back and this row never exercised the no-git path'
exit 1
}
# setup.ps1:1757-1758, the non-fatal branch: git absent, nothing on the consumer
# path needs it, install continues.
if (-not ($log -match 'so git is not needed')) {
Write-Host '::error::setup.ps1 never reported git as absent-but-not-required; the relaxed gate did not run'
exit 1
}
Write-Host 'no winget, no git, and the install completed anyway'
# See the macOS job: proves the leg tests 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 the install added no compiler toolchain
if: always() && steps.install.outcome != 'skipped'
shell: pwsh
run: |
# Windows checked nothing after the install, so setup.ps1 committing to a
# llama.cpp SOURCE build would winget-install CMake (setup.ps1:816-822) and VS
# Build Tools (845-857) and the leg still went green. Git is out of scope on
# purpose: bootstrapping it through winget (1658-1661) is the consumer path the
# visible leg exercises. The VC++ runtime is a runtime, not a toolchain.
$bad = @()
if (-not (Test-Path logs/install.log)) {
Write-Host '::error::no install log, so nothing proves the install stayed toolchain-free'
exit 1
}
# The announcements inside Ensure-BuildToolsForLlamaSourceBuild, which runs only
# for a committed source build. Matched instead of the package ids because
# setup.ps1 PRINTS `winget install ...BuildTools` as manual advice when winget is
# missing, and advice is not an install.
foreach ($m in 'CMake not found -- installing via winget',
'Visual Studio Build Tools not found -- installing via winget') {
if (Select-String -Path logs/install.log -Pattern $m -SimpleMatch -Quiet) {
$bad += "install log reports: $m"
}
}
# winget puts what it installs on the MACHINE PATH, which this step's own process
# PATH (scrubbed, from GITHUB_ENV) never sees, so read the registry copies back
# rather than ask Get-Command. The scrub removed every entry matching these, so
# a match here means the install put one back.
foreach ($scope in 'Machine','User') {
$raw = [System.Environment]::GetEnvironmentVariable('Path', $scope)
if ([string]::IsNullOrWhiteSpace($raw)) { continue }
foreach ($e in ([System.Environment]::ExpandEnvironmentVariables($raw) -split ';')) {
if ($e -match 'CMake|BuildTools|Microsoft Visual Studio|LLVM') {
$bad += "$scope PATH regained $e"
}
}
}
if ($bad) {
Write-Host "::error::the install put a compiler toolchain on this machine: $($bad -join '; ')"
exit 1
}
Write-Host 'no CMake and no VS Build Tools install; the prebuilt contract held'
- name: Assert no source build
if: always() && steps.install.outcome != 'skipped'
shell: pwsh
run: |
# The step above only catches a NEW CMake or VS Build Tools install. The image's
# Visual Studio survives a PATH scrub: setup.ps1's Find-VsBuildTools (763-800)
# reaches it through vswhere and a Program Files scan, and the visible leg logs
# `vs Visual Studio 18 2026 (vswhere)` on the same machine whose pre-flight
# printed `cl ABSENT`. So a dependency that lost its Windows wheel would compile
# against that MSVC and the leg would stay green while macOS and Linux caught
# it. uv really does build sdists here (openai-whisper, antlr4-python3-runtime,
# randomname, argbind), so this is the live path.
& "$env:GITHUB_WORKSPACE/.github/scripts/assert-nobuild.ps1" -LogPath logs/install.log
- name: Assert torch loads, and record what that does and does not prove
if: steps.install.outcome == 'success'
shell: pwsh
run: |
# HONESTY NOTE: the image ships the VC++ 2015-2022 runtime in System32 and cannot
# lose it without breaking the runner, so `import torch` succeeding does NOT
# prove a clean no-winget machine has it -- Test-VCRedistInstalled (setup.ps1:875)
# finds the preinstalled DLL and Ensure-VCRedist (891) short-circuits. Record
# that, then assert what CAN fail.
$sys32 = Join-Path $env:WINDIR 'System32\vcruntime140_1.dll'
Write-Host "preinstalled System32 vcruntime140_1.dll: $(Test-Path $sys32)"
# The managed interpreter, with no fallback to whatever `python` resolves to: the
# usability assert above already hard-fails when it is missing, and a fallback
# would answer this with an interpreter the install did not create.
$py = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\python.exe'
& $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
# ── Windows, genuinely virgin: the same install inside a Windows container ────
# The `win` legs above only SIMULATE absence, and two things they structurally cannot
# test are the VC++ 2015-2022 runtime (it ships in the runner image's System32 and
# cannot be removed without breaking the runner) and a Windows with no Microsoft Store
# at all rather than a winget hidden from PATH. A servercore container answers both, so
# this lane lives here: same premise, same path filters, masked next to real.
#
# Constraints, all load-bearing:
# * `container:` is Linux-only on the Actions runner (actions/runner#1402), so docker
# is driven from ordinary `run:` steps and the payload goes in by `docker cp` --
# actions/checkout inside the container would need git.
# * servercore, not nanoserver: install.ps1 needs Windows PowerShell 5.1, which
# nanoserver does not ship at all.
# * windows-2022, not windows-latest: process isolation needs the host and container
# builds to match, and only the 2022 image pre-caches ltsc2022. windows-latest is
# Server 2025 and caches no Windows images.
windows_container_probe:
name: virgin win container / probe
runs-on: windows-2022
timeout-minutes: 30
env:
IMAGE: mcr.microsoft.com/windows/servercore:ltsc2022
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
persist-credentials: false
# Docker is on every windows-2022 image but is not always already running: one spike
# leg died in 21s on npipe:////./pipe/docker_engine, which misreads as "Windows
# containers are unavailable".
- name: Ensure the Docker daemon is running
shell: pwsh
run: ./.github/scripts/ensure-docker-daemon.ps1
- name: Docker engine facts
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
docker version
$osType = (docker info --format '{{.OSType}}').Trim()
Write-Host "OSType $osType / Isolation $(docker info --format '{{.Isolation}}')"
if ($osType -ne 'windows') {
Write-Host "::error::docker is serving '$osType' containers, not windows; this lane cannot run here"
exit 1
}
docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}'
- name: Start the container
shell: pwsh
run: |
# Never refresh a cached image: process isolation needs the container build <=
# the host build, and MCR has shipped a patched image ahead of the host before
# (actions/runner-images#11582 broke Windows containers for ~2 weeks).
if ((docker images --format '{{.Repository}}:{{.Tag}}') -contains $env:IMAGE) {
Write-Host "using the runner's pre-cached $env:IMAGE (no pull)"
} else {
Write-Host "::warning::$env:IMAGE is not pre-cached; pulling (slow, and it may outrun the host build)"
docker pull $env:IMAGE
if ($LASTEXITCODE -ne 0) { Write-Host "::error::could not pull $env:IMAGE"; exit 1 }
}
# A keepalive entrypoint so each assertion can be its own `docker exec`, and so
# its own step with its own exit code.
docker run -d --name virgin $env:IMAGE cmd /c "ping -t localhost >nul"
if ($LASTEXITCODE -ne 0) { Write-Host "::error::could not start a container from $env:IMAGE"; exit 1 }
Write-Host "isolation: $(docker inspect virgin --format '{{.HostConfig.Isolation}}')"
docker exec virgin cmd /c "mkdir C:\ci"
docker cp "$env:GITHUB_WORKSPACE\." virgin:C:\ci
docker exec virgin cmd /c "dir C:\ci\install.ps1"
- name: Assert the container is genuinely virgin
shell: pwsh
run: |
docker exec virgin powershell.exe -NoLogo -NoProfile -NonInteractive `
-ExecutionPolicy Bypass -File C:\ci\.github\scripts\virgin-windows-probe.ps1 `
*>&1 | Tee-Object -FilePath logs/virginity.log
exit $LASTEXITCODE
- name: Tear down
if: always()
shell: pwsh
run: |
docker rm -f virgin 2>&1 | Out-Null
exit 0
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: virgin-win-probe
path: logs/
retention-days: 7
if-no-files-found: warn
windows_container_install:
name: virgin win container / overlay=${{ matrix.overlay }}
needs: windows_container_probe
runs-on: windows-2022
timeout-minutes: 90
env:
IMAGE: mcr.microsoft.com/windows/servercore:ltsc2022
strategy:
fail-fast: false
matrix:
include:
# The consumer path: install.ps1 from this ref, unsloth from PyPI. So
# studio/setup.ps1 comes out of the RELEASED wheel, which is why this row and the
# overlay one below do not currently reach the same place: see the pin on the
# Install step.
- overlay: false
# This ref's studio/setup.ps1 and install_python_stack.py, via
# UNSLOTH_CI_SOURCE_OVERLAY (install.ps1:2643). Without it a branch changing
# setup.ps1 gets a green run proving nothing about the change.
- overlay: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
persist-credentials: false
- name: Ensure the Docker daemon is running
shell: pwsh
run: ./.github/scripts/ensure-docker-daemon.ps1
- name: Start the container
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path logs | Out-Null
# See the probe job: use the pre-cached image, never refresh it.
if (-not ((docker images --format '{{.Repository}}:{{.Tag}}') -contains $env:IMAGE)) {
Write-Host "::warning::$env:IMAGE not pre-cached; pulling"
docker pull $env:IMAGE
}
docker run -d --name virgin $env:IMAGE cmd /c "ping -t localhost >nul"
if ($LASTEXITCODE -ne 0) { Write-Host "::error::could not start a container from $env:IMAGE"; exit 1 }
docker exec virgin cmd /c "mkdir C:\ci"
docker cp "$env:GITHUB_WORKSPACE\." virgin:C:\ci
docker exec virgin cmd /c "mkdir C:\ci-out"
# Re-run here and not only in `probe`: different runner, and an install leg that
# skipped the check would report on an environment it never verified.
- name: Assert the container is genuinely virgin
shell: pwsh
run: |
docker exec virgin powershell.exe -NoLogo -NoProfile -NonInteractive `
-ExecutionPolicy Bypass -File C:\ci\.github\scripts\virgin-windows-probe.ps1 `
*>&1 | Tee-Object -FilePath logs/virginity.log
exit $LASTEXITCODE
# AFTER the virginity assertion, so that assertion still proves what it says. A fresh
# container ships an almost empty trusted-root store while a real desktop fills it
# via automatic root update, so seeding makes this MORE representative. Needed
# because studio/install_node_prebuilt.py downloads Node with bare
# urllib.request.urlopen, reads the empty Windows ROOT store and gets
# CERTIFICATE_VERIFY_FAILED; uv and pip bundle certifi. Reported separately.
- name: Seed the container's trusted root CA store
shell: pwsh
run: |
# -generateSSTFromWU pulls each root from ctldl.windowsupdate.com, which times
# out often enough to be the leg's main flake (staging run 30423072537 died on
# WinHttp 12002 while the sibling row seeded fine). Retry, but never tolerate a
# total failure: without the roots Node's urllib download later fails with
# CERTIFICATE_VERIFY_FAILED.
for ($i = 1; $i -le 3; $i++) {
docker exec virgin cmd /c "certutil -generateSSTFromWU C:\roots.sst && certutil -addstore -f Root C:\roots.sst" `
*>&1 | Select-Object -Last 15
if ($LASTEXITCODE -eq 0) { break }
Write-Host "::warning::root CA seeding attempt $i failed; retrying"
Start-Sleep -Seconds 15
}
if ($LASTEXITCODE -ne 0) {
Write-Host '::error::could not seed the container root CA store after 3 attempts; Python-side HTTPS will fail'
exit 1
}
- name: Install into the virgin container
id: install
shell: pwsh
# RELEASE-LAG PIN, overlay=false only. A Server Core container has no Microsoft
# Store and so no winget, ever, and studio/setup.ps1 used to hard-stop on a
# winget-only git gate and reach for winget again for the VC++ runtime. #7549
# relaxed both and this branch has it, but the released wheel does not: unpacking
# unsloth 2026.7.5 (uploaded 2026-07-23, #7549 landed on the 28th) shows its
# setup.ps1 still carrying the old gate, so the row that deliberately installs
# from PyPI cannot get past it. Release lag, not a product gap; nothing in this
# branch can change it, only the next RELEASE, not a merge. The overlay row runs
# the same install against this ref's setup.ps1 and gates unconditionally, and
# the step below accepts only that exact signature, hard-erroring the moment the
# released wheel catches up.
continue-on-error: ${{ !matrix.overlay }}
run: |
$overlayArg = if ('${{ matrix.overlay }}' -eq 'true') { 'C:\ci' } else { '' }
docker exec virgin powershell.exe -NoLogo -NoProfile -NonInteractive `
-ExecutionPolicy Bypass -File C:\ci\.github\scripts\virgin-windows-install.ps1 `
-Overlay "$overlayArg" *>&1 | Tee-Object -FilePath logs/install-outer.log
exit $LASTEXITCODE
# The overlay row runs this ref's studio/setup.ps1, so it carries #7549 and has to
# install end to end. The in-container harness already asserts the venv interpreter,
# the unsloth CLI, `import torch`, the no-winget path, the overlay marker and
# nobuild, and exits 1 listing every failure -- so the Install step gating is most
# of the assertion. Added here is the part this lane alone can prove.
- name: Assert the virgin container install proved what this lane exists for
if: matrix.overlay
shell: pwsh
run: |
$log = Get-Content logs/install-outer.log -Raw
# A `docker exec` that lost its container also exits 0, so read the harness's own
# verdict rather than trust the exit code alone.
if (-not ($log -match 'VIRGIN WINDOWS CONTAINER INSTALL PASSED')) {
Write-Host '::error::the install step exited 0 but the in-container harness never printed its passing verdict'
exit 1
}
# The overlay hook is this PR's own feature and gates unconditionally: without it
# this row is indistinguishable from the released-wheel one.
if (-not ($log -match 'CI: overlaying source checkout')) {
Write-Host '::error::the overlay row never overlaid the checkout, so it only tested the released package'
exit 1
}
# Git: no Store, no winget, no git, and nothing on the consumer path needs it. The
# relaxed gate is the only reason this row gets past setup.ps1 at all.
if ($log -match 'Git is required but could not be installed automatically') {
Write-Host '::error::studio/setup.ps1 stopped at the unconditional git gate; the relax to --local and llama.cpp source-build installs has regressed'
exit 1
}
if (-not ($log -match 'so git is not needed')) {
Write-Host '::error::setup.ps1 never reported git as absent-but-not-required, so this container was not gitless and the relaxed gate went untested'
exit 1
}
# VC++: this container is the ONLY environment in the workflow whose System32 does
# not already ship the 2015-2022 runtime (the hosted legs cannot remove it
# without breaking the runner), so it is the only place the direct aka.ms
# download can be proved to run rather than be short-circuited by
# Test-VCRedistInstalled. Both halves: the fallback was taken, and it worked.
if (-not ($log -match 'downloading the runtime directly')) {
Write-Host '::error::Ensure-VCRedist never took the direct-download fallback, so a container with no VC++ runtime and no winget did not exercise it'
exit 1
}
if ($log -match 'Could not install the VC\+\+ Redistributable automatically') {
Write-Host '::error::the direct VC++ runtime download ran but left the runtime uninstalled'
exit 1
}
# The harness already ran `import torch` against the managed interpreter, which is
# what needs VCRUNTIME140_1.dll; this announces that the DLL got there rather
# than having been there all along.
Write-Host '::notice::no Store, no winget, no git and no preinstalled VC++ runtime, and the install completed anyway'
# RELEASE-LAG PIN (overlay=false). See the Install step: this row installs unsloth
# from PyPI on purpose and the released setup.ps1 predates #7549. continue-on-error
# would otherwise tolerate a bootstrap outage or an unrelated early exit exactly like
# the intended diagnostic, so every branch here but the pinned failure exits 1 and
# fails the (required) job.
- name: Assert the released-wheel row failed only on release lag
if: always() && !matrix.overlay && steps.install.outcome != 'skipped'
shell: pwsh
run: |
if ('${{ steps.install.outcome }}' -eq 'success') {
Write-Host '::error::the released wheel now installs in a virgin container, so it carries the relaxed #7549 gates. Delete this pin and drop continue-on-error from the Install step so this row gates.'
exit 1
}
if (-not (Test-Path logs/install-outer.log)) {
Write-Host '::error::the container install produced no log'
exit 1
}
$log = Get-Content logs/install-outer.log -Raw
# The pinned signature is the OLD gate wording, which #7549 deleted. Its
# disappearance from a released wheel is the flip condition; until then a failure
# anywhere else has to be red.
$gitGate = $log -match 'Git is required but could not be installed automatically'
$vcGate = $log -match 'torch failed to import'
if (-not ($gitGate -or $vcGate)) {
Write-Host '::error::the container install failed at neither the released winget-only git gate nor the missing VC++ runtime; this is a new failure'
exit 1
}
# `-or` on its own is too generous. virgin-windows-install.ps1:97 runs the torch
# assertion whenever the venv interpreter exists, whatever the installer did, and
# this image has no VC++ runtime, so ANY failure after venv creation -- a Node
# download, a setup step, a bad prebuilt -- arrives here carrying the $vcGate
# text and was accepted as the pinned outcome. Enumerate what the harness
# actually recorded instead: it prints one `::error::<reason>` per entry of its
# $failures list (that script:151), and every one has to be a pinned gate.
# Anchored, because it also dumps the install log tail indented two spaces and
# those copies must not count.
$recorded = @(Get-Content logs/install-outer.log |
ForEach-Object { if ($_ -match '^::error::(.+)$') { $Matches[1].Trim() } })
Write-Host "recorded failures: $($recorded.Count)"
$recorded | ForEach-Object { Write-Host " $_" }
if ($recorded.Count -eq 0) {
Write-Host '::error::the container install failed but recorded no ::error:: line, so nothing identifies which gate stopped it'
exit 1
}
# The git gate makes install.ps1 exit non-zero, the missing runtime makes the
# torch assert fail. Nothing else is pinned.
$pinned = @('^installer exited \d+$', '^torch failed to import from the managed Python')
$unexpected = @($recorded | Where-Object { $r = $_; -not ($pinned | Where-Object { $r -match $_ }) })
if ($unexpected.Count -gt 0) {
Write-Host "::error::the container install recorded a failure outside the pinned gates: $($unexpected -join '; '); this is a new failure"
exit 1
}
# And a non-zero exit has to BE the git gate: without this a post-venv failure
# that also exits 1 is indistinguishable from the pinned one.
if (($recorded | Where-Object { $_ -like 'installer exited*' }) -and
-not ($gitGate -and ($log -match 'unsloth studio setup failed \(exit code 1\)'))) {
Write-Host '::error::the installer exited non-zero somewhere other than the winget-only git gate in the released studio/setup.ps1; this is a new failure'
exit 1
}
Write-Host '::notice::known outcome: the released wheel still carries the winget-only git gate and the winget-only Ensure-VCRedist that #7549 replaced. Retire this pin with the next release.'
- name: Recover the install log from the container
if: always()
shell: pwsh
run: |
docker cp virgin:C:\ci-out\install.log logs/install.log 2>&1 | Out-Null
if (Test-Path logs/install.log) { Write-Host "recovered $((Get-Item logs/install.log).Length) bytes" }
else { Write-Host '::warning::no install log inside the container' }
exit 0
- name: Tear down
if: always()
shell: pwsh
run: |
docker rm -f virgin 2>&1 | Out-Null
exit 0
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: virgin-win-container-overlay-${{ matrix.overlay }}
path: logs/
retention-days: 7
if-no-files-found: warn