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.
1728 lines
94 KiB
YAML
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
|