# 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 /llama.cpp and the venv at # /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::` 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