Make the Linux and Windows desktop legs clean, and honour published on every macOS delivery

The desktop workflow claims all three platforms are stripped, but only macOS
and Windows had a strip step and the Windows one scrubbed the process PATH
only. Both gaps let a bundle that needs a developer toolchain pass the one
workflow whose premise is that it must not.

Linux: the job ignored strip_toolchain entirely and ran the bundled install.sh
with the runner's git, gcc, cmake and make in /usr/bin. clean-machine-env.sh
now has a Linux --remove branch that moves the resolved tool binaries aside,
recorded in restore.sh, and the job calls it plus `assert absent` after the apt
step (the .deb install needs dpkg) and before the bundled installer, with a
restore step to match macOS. The loop repeats per tool so a name present in
both /usr/bin and /usr/local/bin is fully masked rather than half masked.

Windows: rewriting $env:PATH does not survive the bundled install.ps1, which
calls Refresh-SessionPath (318-337) and rebuilds $env:Path from the Machine and
User registry values, and py.exe in C:\Windows reaches the toolcache whatever
PATH says. Ported the on-disk toolcache rename, the Machine/User registry scrub
and the py -3.11/-3.12/-3.13 start probe from clean-machine-install-ci.yml, so
the strip is proven rather than assumed.

Windows preflight: the log step was Test-Path, Get-Content and Select-String,
none of which can fail, so an app that hangs before preflight passed on the
90 second liveness check alone. It now asserts a tauri.log exists and carries a
`desktop_preflight completed disposition=` line, the same unconstrained check
macOS and Linux already make. The disposition VALUE is deliberately not
constrained: ManagedReady over an unbootable venv is the reported bug.

installer_source on macOS: only the pipe delivery branched on it, so a
`published` dispatch ran the checked-out script on six of the eight macOS rows
while the run was labelled published. The script is now resolved once at the
top of the Install step and used by the file and tauri deliveries; pipe still
re-fetches through the live transport, because that is half of what it tests.
Linux, WSL and Windows already honoured the input.

Also shortened the comments across the changed files, keeping the reasoning
that says why each check exists.
This commit is contained in:
danielhanchen 2026-07-28 23:54:57 +00:00
commit b905784f77
7 changed files with 455 additions and 383 deletions

View file

@ -16,33 +16,31 @@
#
# ── What each leg actually puts under test ────────────────────────────────────
# install.sh / install.ps1 come from this ref, but they install unsloth FROM PyPI,
# because that is the consumer path and it has to stay that way. Everything
# Python-side is therefore read out of the RELEASED wheel: studio/setup.sh,
# studio/setup.ps1, studio/install_python_stack.py, and every requirements and
# constraints file those resolve through Path(__file__). Left alone, this workflow
# can only ever validate the two shell installers, and a branch that changes
# constraints.txt or setup.ps1 gets a green run that proves nothing about the
# change. The `Assert the Fedora outcome is a known one` step below was already
# carrying a hand-written workaround for exactly this.
# the consumer path, which has to stay that way. Everything Python-side is therefore
# read out of the RELEASED wheel: studio/setup.sh, setup.ps1,
# install_python_stack.py, and every requirements and constraints file those reach
# through Path(__file__). Left alone this workflow validates only the two shell
# installers, and a branch changing constraints.txt or setup.ps1 gets a green run
# that proves nothing about the change; the `Assert the Fedora outcome is a known
# one` step below was already working around exactly that.
#
# So legs with `overlay: true` re-point the venv at this ref before studio setup
# runs, via UNSLOTH_CI_SOURCE_OVERLAY (install.sh / install.ps1, just above their
# "Run studio setup" section): a `--no-deps` editable install of the checkout.
# That makes `import studio` resolve to the working tree, so the existing
# setup-script lookup finds this ref's setup.sh / setup.ps1 and install_python_stack
# reads this ref's constraints. It is deliberately NOT `install.sh --local`:
# --local also installs `unsloth-zoo @ git+https://...`, which genuinely needs git,
# and git absence is the whole point of the masked legs. The overlay resolves no
# dependencies and clones nothing, so it still works with git, cmake and the
# compilers all gone.
# So `overlay: true` legs re-point the venv at this ref before studio setup runs, via
# UNSLOTH_CI_SOURCE_OVERLAY (install.sh / install.ps1, just above their "Run studio
# setup" section): a `--no-deps` editable install of the checkout. `import studio`
# then resolves to the working tree, so the existing setup-script lookup finds this
# ref's setup.sh / setup.ps1 and install_python_stack reads this ref's constraints.
# Deliberately NOT `install.sh --local`: that also installs
# `unsloth-zoo @ git+https://...`, which genuinely needs git, and git absence is the
# whole point of the masked legs. The overlay resolves nothing and clones nothing, so
# it survives git, cmake and the compilers all being gone.
#
# Legs left on `overlay: false`, and why:
# mac */mask/pipe the `curl | sh` shape a user runs. Kept end-to-end on the
# released package so a broken PyPI release still shows up.
# mac macos-14/trace `notools` asserts the installer never reaches for git, and
# the editable build itself calls `git rev-parse` /
# `git archive` through setuptools-scm's file finder. An
# overlay here would answer the leg's own question for it.
# `git archive` through setuptools-scm's file finder, so an
# overlay would answer the leg's own question for it.
# linux ubuntu2404-nonroot dies at the elevation gate before a venv exists.
# wsl only install.sh is copied into the distro; there is no
# source tree inside WSL to overlay from.
@ -59,18 +57,18 @@ on:
- 'studio/install_python_stack.py'
# The overlay exists so a constraints or requirements change is actually
# exercised here (see the header). Without these paths the one workflow that
# resolves them on a machine with no compiler and no wheels cached never runs
# for the PR that changes them, and the update-smoke jobs cannot stand in:
# they start from a preinstalled Python and full developer tooling.
# resolves them with no compiler and no cached wheels never runs for the PR that
# changes them, and the update-smoke jobs cannot stand in: they start from a
# preinstalled Python and full developer tooling.
- 'studio/backend/requirements/**'
- '.github/scripts/clean-machine-*.sh'
- '.github/workflows/clean-machine-install-ci.yml'
push:
branches: [main]
# Same list as the PR filter. A direct push to main that changed setup.sh,
# setup.ps1, install_python_stack.py, a requirements file or a clean-machine
# helper skipped this workflow entirely, so the post-merge run that is supposed
# to be the backstop for exactly those files never happened.
# Same list as the PR filter. A direct push to main touching setup.sh, setup.ps1,
# install_python_stack.py, a requirements file or a clean-machine helper skipped
# this workflow entirely, so the post-merge backstop for exactly those files never
# happened.
paths:
- 'install.sh'
- 'install.ps1'
@ -100,9 +98,8 @@ env:
UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
# No wildcard bind -> no ifconfig.me / check-host.net calls on the startup path.
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
# Without this, run_install_cmd (install.sh:193-243) sends every `uv pip install`
# to a temp file and DELETES it on success, so the `nobuild` assertion can only
# ever report "built: none".
# 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:
@ -112,28 +109,28 @@ jobs:
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.
# Explicit legs, not a full cross-product: the interesting dimensions are (does
# the toolchain exist) x (how the script is delivered), not every pairing.
strategy:
fail-fast: false
matrix:
include:
# `overlay` decides whether this ref's Python code is put under test at all;
# see the header. The pipe legs stay on the released package on purpose.
# `overlay` decides whether this ref's Python is put under test at all; see
# the header. The pipe legs stay on the released package on purpose.
#
# The reported failure, in the shape users run it. Default install (with
# torch) because that is what a consumer actually gets.
# 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), which
# would plant the very evidence `notools` exists to look for.
# 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 instead of letting it hide the gate under test.
# (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}
@ -179,11 +176,11 @@ jobs:
- name: Verify the trace actually records
if: matrix.mode == 'trace'
run: |
# `notools` reads an absence, so a shim dir that never reached PATH is
# indistinguishable from an installer that touched nothing, and the one leg
# carrying that assertion would pass no matter what the installer did.
# Prove the wrapper records before trusting an empty file. macOS never
# probes git off the --local path, so this must be an explicit call.
# `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
@ -201,28 +198,41 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code.
HF_TOKEN: ${{ github.event_name != 'pull_request' && secrets.HF_TOKEN || '' }}
# Empty, and therefore ignored by install.sh, on the non-overlay legs.
# Also empty for `installer_source: published`, where the script under test
# is production's and has no such hook: overlaying this ref's Python onto it
# would report on neither one honestly.
# 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 and 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 `published` dispatch asks whether unsloth.ai's script works. Only `pipe`
# honoured it, so six of the eight macOS rows ran the checked-out script and
# were still reported as published coverage. Resolve it 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)
# Plain file execution isolates "installer logic broken" from
# "curl-pipe delivery broken".
bash install.sh $FLAGS 2>&1 | tee logs/install.log || rc=$?
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. This input is empty on
# pull_request/push, so only an explicit dispatch tests unsloth.ai.
# 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 | sh -s -- $FLAGS 2>&1 | tee logs/install.log || rc=$?
else
@ -243,7 +253,7 @@ jobs:
# before doing any work. The runner is ephemeral, so the real home is
# as disposable as the override.
env -u UNSLOTH_STUDIO_HOME \
bash install.sh --tauri $FLAGS < /dev/null 2>&1 | tee logs/install.log || rc=$?
bash "$SCRIPT" --tauri $FLAGS < /dev/null 2>&1 | tee logs/install.log || rc=$?
;;
esac
echo "install_rc=$rc" >> "$GITHUB_OUTPUT"
@ -255,9 +265,9 @@ jobs:
fi
exit "$rc"
# Without this the gap comes back silently: install.sh ignores an unset
# UNSLOTH_CI_SOURCE_OVERLAY, so a typo in the matrix or the expression would
# put every leg back on the released wheel and nothing in the run would say so.
# Without this the gap returns silently: install.sh ignores an unset
# UNSLOTH_CI_SOURCE_OVERLAY, so a typo in the matrix or the expression puts every
# leg back on the released wheel with nothing in the run saying so.
- name: Assert this ref's Python was really put under test
if: matrix.overlay && inputs.installer_source != 'published' && steps.install.outcome == 'success'
run: |
@ -272,10 +282,10 @@ jobs:
run: |
set -a; . ./clean-machine.env; set +a
checks="nobuild"
# `absent` ran only BEFORE the install, so an installer that quietly
# selected the CLT or installed a compiler left the leg green while every
# later source build could succeed -- the exact behaviour the assert script
# says `absent` guards the whole run against. Re-run it after the install.
# `absent` ran only BEFORE the install, so an installer that quietly selected
# the CLT or installed a compiler left the leg green while every later source
# build could succeed, the exact behaviour `absent` claims to guard the whole
# run against. Re-run it afterwards.
[ "${{ matrix.mode }}" = "mask" ] && checks="$checks absent"
[ "${{ matrix.mode }}" = "trace" ] && checks="$checks notools"
UNSLOTH_CLEAN_ALLOW_WORKING='${{ matrix.allow_working }}' \
@ -285,9 +295,9 @@ jobs:
if: steps.install.outcome == 'success'
run: |
set -a; . ./clean-machine.env; set +a
# The tauri leg cannot honour UNSLOTH_STUDIO_HOME (see Install), so it went
# to the legacy root: llama.cpp sits at <root>/llama.cpp and the venv at
# <root>/studio, so this is ~/.unsloth, not ~/.unsloth/studio.
# 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
@ -303,8 +313,8 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
# Two matrix rows differ only in `flags`, so flags must be in the name:
# artifacts are immutable per run and the second upload 409s.
# 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/
@ -321,8 +331,8 @@ jobs:
container: ${{ matrix.image }}
timeout-minutes: 40
continue-on-error: ${{ matrix.experimental }}
# Container jobs default to `sh -e` (dash), where `set -o pipefail` is an
# "Illegal option" that kills the step before the installer even starts.
# 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
@ -330,8 +340,8 @@ jobs:
fail-fast: false
matrix:
include:
# Root + apt available: install.sh's _smart_apt_install should self-heal
# from a base image with no curl, git, gcc or cmake at all.
# 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
@ -342,17 +352,16 @@ jobs:
runner: ubuntu-24.04-arm
experimental: false
overlay: true
# No elevation: today this hard-fails at install.sh:856-861. Expected
# failure; the point is to pin the message and prove it is actionable
# rather than a bare `curl: (56)`. No overlay: it never gets as far as a
# venv, so there would be nothing to overlay into.
# No elevation: today this hard-fails at install.sh:856-861. Expected; the
# point is to pin the 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
image: ubuntu:24.04
runner: ubuntu-latest
experimental: true
overlay: false
# Non-apt: today this hard-fails at install.sh:2034. Expected failure;
# forces the decision on whether dnf/pacman/zypper get supported.
# 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
@ -366,16 +375,15 @@ jobs:
printf '%-8s %s\n' "$t" "$(command -v $t 2>/dev/null || echo ABSENT)"
done | tee /tmp/container-baseline.txt
# The advertised `curl | sh` cannot even start on an image without curl, so the
# 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 to get this ref's source into the container is
# to fetch and unpack an archive over the same transport. Neither is a
# compiler, git or cmake, so the clean-machine premise is untouched. Both
# are usually already in the base image; naming them just makes it certain.
# (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.
pkgs="ca-certificates curl"
if [ "${{ matrix.overlay }}" = "true" ]; then pkgs="$pkgs tar gzip"; fi
if command -v apt-get >/dev/null 2>&1; then
@ -386,8 +394,8 @@ jobs:
# No actions/checkout on purpose: it needs git, and a container with git
# preinstalled is not the clean machine under test. Fetch over the transport
# above, and fetch the INSTALLER from the same ref so these legs can validate a
# fix instead of only the published script.
# 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
@ -403,9 +411,9 @@ jobs:
fi
wc -l install.sh
# The overlay needs a source tree, and these legs deliberately have no
# The overlay needs a source tree and these legs deliberately have no
# actions/checkout. codeload serves the same commit as a tarball over plain
# HTTPS, so this ref's Python code gets in without a git client.
# 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: |
@ -420,11 +428,11 @@ jobs:
if: matrix.label == 'ubuntu2404-nonroot'
run: |
useradd -m tester
# Switching user without a login shell keeps the caller's environment, so
# the workflow-wide UNSLOTH_STUDIO_HOME follows tester in, and install.sh
# Switching user without a login shell keeps the caller's environment, so the
# workflow-wide UNSLOTH_STUDIO_HOME follows tester in, and install.sh
# validates that override in _resolve_studio_destinations (503-559), long
# before the elevation gate (840-861). Without a writable target this leg
# dies on "cannot be created" instead of on "cannot elevate".
# before the elevation gate (840-861). Without a writable target this leg dies
# on "cannot be created" rather than "cannot elevate".
mkdir -p "$UNSLOTH_STUDIO_HOME"
# No sudo installed and not root -> exercises the "cannot elevate" branch.
chown -R tester logs install.sh "$UNSLOTH_STUDIO_HOME"
@ -434,16 +442,16 @@ jobs:
if: matrix.label != 'ubuntu2404-nonroot'
run: |
set -o pipefail
# Resolved here rather than in `env:` so it tracks the step's real working
# directory: a container job remaps the workspace and github.workspace is
# not something this needs to depend on.
# Resolved here, not in `env:`, so it tracks the step's real working
# directory: a container job remaps the workspace, and github.workspace is not
# something this needs to depend on.
if [ -d ci-source ]; then
export UNSLOTH_CI_SOURCE_OVERLAY="$PWD/ci-source"
echo "overlaying this ref's source from $UNSLOTH_CI_SOURCE_OVERLAY"
fi
rc=0
# Piped: the advertised command, and the shape that turns an early exit
# into curl:(56).
# 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"
@ -456,16 +464,15 @@ jobs:
su tester -c 'cat install.sh | sh' > logs/install.log 2>&1 || rc=$?
echo "installer exit code: $rc"
tail -40 logs/install.log
# It may legitimately fail; what must NOT happen is an unexplained exit or
# a bare broken-pipe error standing in for a real diagnosis.
# It may legitimately fail; what must NOT happen is an unexplained exit or a
# bare broken-pipe error standing in for a real diagnosis.
if [ "$rc" != "0" ] && ! grep -qiE "sudo is not available|apt-get install|missing:|permission" logs/install.log; then
echo "::error::unprivileged install failed with no actionable message"
exit 1
fi
# This leg is continue-on-error like the nonroot one, so without the same check
# a bootstrap outage or an unrelated early exit would be tolerated exactly like
# the intentional diagnostic.
# continue-on-error like the nonroot leg, so without the same check 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: |
@ -478,30 +485,28 @@ jobs:
# install.sh comes from this ref, so which of the two accepted outcomes
# applies depends on which dependency gate this ref carries.
if grep -q "using prebuilt llama.cpp (missing:" logs/install.log; then
# The gate no longer hard-stops on a non-apt distro: it warns that the
# optional build tools are absent and carries on. Reaching this warning is
# what proves the Linux gate did not stop the install.
# Past that point the accepted failure used to be release lag: install.sh
# came from this ref but unsloth from PyPI, and the released
# studio/install_python_stack.py has no "skip the triton kernels when git
# is missing" guard, so it fetched the git+https triton_kernels
# requirement on a machine with no git. This leg is now overlaid with this
# ref's Python (see the header), so that guard is this ref's own code and
# the triton failure must NOT come back. Accepting it here would be
# accepting a regression in the guard as if it were release lag.
# The gate no longer hard-stops on a non-apt distro: it warns 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. A failure here is this ref's own.
# 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 if it
# were the intentional diagnostic.
# 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
@ -518,8 +523,8 @@ jobs:
echo "overlay applied; this leg exercised this ref's Python"
# nobuild only reads the log, so an installer that exits 0 having done nothing
# satisfies it. These required Linux rows had no check that the install
# produced anything runnable, unlike the WSL and Windows jobs.
# 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'
run: |
@ -555,8 +560,8 @@ jobs:
# environment, which cannot catch anything about a real WSL.
#
# No third-party action: the official Ubuntu rootfs plus `wsl --import` is
# deterministic and checksum-verifiable, and adds no supply-chain dependency to a
# repo that audits its lockfiles.
# deterministic and checksum-verifiable, adding no supply-chain dependency to a repo
# that audits its lockfiles.
wsl:
name: wsl ubuntu-24.04
runs-on: windows-latest
@ -584,20 +589,20 @@ jobs:
}
wsl --import unsloth-ci "$PWD/wsl-dist/instance" "$PWD/wsl-dist/rootfs.tar.gz" --version 2
wsl -d unsloth-ci -- uname -a
# A freshly imported rootfs is genuinely bare: no curl, git or compiler.
# That is the clean machine, not a simulation of one.
# A freshly imported rootfs is genuinely bare: no curl, git or compiler. The
# clean machine, not a simulation of one.
wsl -d unsloth-ci -- sh -c 'for t in curl wget git gcc cmake python3 sudo; do printf "%-8s %s\n" "$t" "$(command -v $t || echo ABSENT)"; done'
- name: Install inside WSL, piped exactly as documented
shell: pwsh
run: |
# Only ca-certificates + curl: the advertised one-liner cannot start without
# a transport. Everything else must come from the installer.
# 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 that selects `published` is asking whether the script on
# unsloth.ai works; running the checked-out one here and reporting the leg
# green answered a different question under the same name. Empty on
# pull_request/push, so automatic runs stay on this ref.
# A dispatch selecting `published` asks whether unsloth.ai's script works;
# running the checked-out one and reporting the leg green 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'
@ -606,22 +611,21 @@ jobs:
exit 1
}
} else {
# Copy the script in rather than reaching across /mnt/c: a DrvFs path
# brings Windows permissions and CRLF risk, neither of which a real WSL
# user has.
# 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 the script under test is this ref not production's.
# 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"
# Printing the code discarded it. The CLI check in the next step does not
# compensate: install.sh links the `unsloth` shim (4174-4182) BEFORE it
# reports a failing studio/setup.sh (4219-4230), so a late setup failure
# leaves a shim whose --version succeeds and the whole job looked green.
# Printing the code discarded it, and the next step's CLI check does not
# compensate: install.sh links the `unsloth` shim (4174-4182) BEFORE it reports
# a failing studio/setup.sh (4219-4230), so a late setup failure leaves a shim
# whose --version succeeds and the job looked green.
if ($installRc -ne 0) {
Write-Host "::error::WSL installer exited $installRc"
exit $installRc
@ -634,11 +638,10 @@ jobs:
# The platform line proves the wsl branch was taken rather than plain linux.
Select-String -Path logs/wsl-install.log -Pattern 'platform|\[TAURI:DIAG\]|wsl' -ErrorAction SilentlyContinue |
Select-Object -First 10
# Printing could not fail, and that alternation also matches
# `platform linux`: if detection regresses, every WSL branch is skipped and
# this job still passes as a plain-Linux install, the one thing no other job
# covers. `step` writes the label in reverse video, so strip ANSI first or
# an anchored match can never hit.
# Printing could not fail, and that alternation also matches `platform linux`:
# if detection regresses, every WSL branch is skipped and this job still passes
# as a plain-Linux install, the one thing no other job covers. `step` writes
# the label in reverse video, so strip ANSI or an anchored match never hits.
$esc = [char]27
$platformLines = @(
Get-Content logs/wsl-install.log -ErrorAction SilentlyContinue |
@ -691,33 +694,31 @@ jobs:
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.
# Ensure-VCRedist silently does not run, leaving torch unable to load, hence
# the explicit `import torch` assert below.
#
# This leg fails at studio/setup.ps1:1652-1670, the unconditional
# "Git is required but could not be installed automatically" gate: no winget
# means no way to fetch git, and setup.ps1 refuses to continue without it.
# Before the overlay that failure came out of the RELEASED setup.ps1 and said
# nothing about this ref. It now comes out of this ref's own copy, which
# still carries the same gate, so the leg is red for a real and currently
# unfixed reason rather than for an untestable one. Relaxing that gate to
# `--local` and llama.cpp source builds is what turns it green, and this
# overlay is what will let this workflow see it happen.
# It fails at studio/setup.ps1:1652-1670, the unconditional "Git is required
# but could not be installed automatically" gate: no winget means no way to
# fetch git. Before the overlay that failure came out of the RELEASED setup.ps1
# and said nothing about this ref; it now comes out of this ref's own copy,
# which carries the same gate, so the leg is red for a real and currently
# unfixed reason rather than an untestable one. Relaxing that gate to --local
# and llama.cpp source builds turns it green, and the overlay is what lets this
# workflow see that happen.
- os: windows-latest
winget: 'masked'
experimental: false
overlay: true
# Windows on ARM gets as far as the dependency install and then stops on
# two packages that publish no win_arm64 wheel at all:
# pyarrow==25.0.0 (pulled in by datasets) -- PyPI has win_amd64 only,
# so uv falls back to the sdist and its CMake configure fails
# hf-transfer==0.1.9 -- a maturin/Rust sdist whose openssl-sys build
# script wants perl, which the image does not have
# The ARM-specific handling added for this platform is working: the log
# shows "windows on arm: skipping torchaudio", and torch 2.10.0+cpu and
# torchvision both install from wheels. The redness that remains is a
# real product gap on this platform, not a gap in the simulation, so the
# leg stays experimental and keeps reporting it rather than hiding it.
# Windows on ARM reaches the dependency install and stops on two packages
# that publish no win_arm64 wheel at all:
# pyarrow==25.0.0 (via datasets) -- PyPI has win_amd64 only, so uv falls
# back to the sdist and its CMake configure fails
# hf-transfer==0.1.9 -- a maturin/Rust sdist whose openssl-sys build script
# wants perl, which the image does not have
# The ARM handling itself works: the log shows "windows on arm: skipping
# torchaudio", and torch 2.10.0+cpu and torchvision install from wheels. What
# is left is a real product gap on this platform, not a gap in the simulation,
# so the leg stays experimental and keeps reporting it.
- os: windows-11-arm
winget: 'visible'
experimental: true
@ -735,19 +736,18 @@ jobs:
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.
# from PATH. A full Visual Studio uninstall is not realistic in CI (registry +
# vswhere discovery, slow, may need a reboot), so PATH and env scrubbing is the
# honest approximation, recorded as such.
$drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\',
'CMake', 'Microsoft Visual Studio', 'BuildTools', 'LLVM',
'MSYS', 'mingw', 'Strawberry')
# winget is an app-execution alias in ...\Local\Microsoft\WindowsApps, which
# the blanket drop above removes on EVERY leg -- so winget=visible was
# silently running the same no-winget fallback as winget=masked. Resolve it
# before the scrub and hand it back through a shim, so the visible leg gets
# winget without the Store's python.exe alias back. windows-11-arm has no
# winget on the hosted image (actions/runner-images#14083), so only
# windows-latest can carry it.
# the blanket drop removes on EVERY leg, so winget=visible was silently running
# the same no-winget fallback as winget=masked. Resolve it before the scrub and
# hand it back through a shim: the visible leg gets winget without the Store's
# python.exe alias returning. 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 = {
@ -774,7 +774,7 @@ jobs:
}
# Take the toolcache Python off disk, not just off PATH: py.exe lives in
# C:\Windows (which must stay) and uv does its own interpreter discovery, so
# both reach the toolcache whatever PATH says -- which is how a leg printing
# both reach the toolcache whatever PATH says. That is how a leg printing
# `python ABSENT` still installed with the runner's 3.13.14.
foreach ($tc in @("$env:AGENT_TOOLSDIRECTORY\Python", 'C:\hostedtoolcache\windows\Python')) {
if ($tc -and (Test-Path $tc)) {
@ -786,11 +786,11 @@ jobs:
$newPath = ($kept -join ';')
"PATH=$newPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# install.ps1's Refresh-SessionPath (318-337, called at 1246/1278/1295/1360/
# 1369/2797) rebuilds $env:Path from the Machine and User registry values, so
# scrubbing only the process PATH lasts until the first bootstrap refresh,
# after which Git/CMake/VS/LLVM are back and the rest of the install is no
# longer clean. The runner is ephemeral, so rewrite the registry copies too.
# Expand first: SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ
# 1369/2797) rebuilds $env:Path from the Machine and User registry values, 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. Expand first:
# SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ
# (dotnet/runtime#1442).
foreach ($scope in 'Machine','User') {
$raw = [System.Environment]::GetEnvironmentVariable('Path', $scope)
@ -813,22 +813,21 @@ jobs:
run: |
$leaked = @()
# `py` too: the launcher lives in C:\Windows, which the scrub keeps, and it
# finds the toolcache Python that the scrub only removed from PATH.
# 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
# any version registered outside the two renamed toolcache directories gets
# reused and Python bootstrap never runs. Exempting `py` without running it
# left that unchecked.
# leak: Find-CompatiblePython (install.ps1:1130-1153) probes `py` first, so any
# version registered outside the two renamed toolcache directories gets reused
# and Python bootstrap never runs. Exempting `py` left that unchecked.
if (Get-Command py -ErrorAction SilentlyContinue) {
# -0p prints the launcher's REGISTRY view. The mask step renames the
# toolcache directory on disk but cannot rewrite those registry entries,
# so -0p keeps naming paths that no longer exist. It is context for a
# failure, never evidence of one -- only a probe that starts counts.
# -0p prints the launcher's REGISTRY view. The mask renames the toolcache
# directory on disk but cannot rewrite those entries, so -0p keeps naming
# paths that no longer exist: context for a failure, never evidence of one.
# Only a probe that actually 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') {
@ -838,13 +837,12 @@ jobs:
Write-Host ("py {0} -> exit {1}: {2}" -f $v, $rc, (($out | Out-String).Trim() -replace '\r?\n', ' / '))
if ($rc -eq 0) { $leaked += "py $v -> $out" }
}
# A probe that FAILS is the outcome we want, but it leaves $LASTEXITCODE
# non-zero, and Get-Command/Write-Host are cmdlets that never reset it.
# The runner appends
# A FAILING probe is the outcome we want, but it leaves $LASTEXITCODE
# non-zero and cmdlets never reset it. The runner appends
# if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }
# to every pwsh step (actions/runner#351), so all three Windows legs
# exited 1 with no ::error:: printed, on machines that were in fact clean
# -- and never reached the Install step at all.
# to every pwsh step (actions/runner#351), so all three Windows legs exited 1
# with no ::error:: printed, on machines that were in fact clean, and never
# reached the Install step.
$global:LASTEXITCODE = 0
}
# Printing alone could not fail, and the leg was green while not clean: run
@ -862,17 +860,16 @@ jobs:
exit 1
}
} elseif ('${{ matrix.os }}' -eq 'windows-latest' -and -not $winget) {
# Without this the visible leg quietly degrades into a second masked leg
# and no job in this workflow exercises the normal winget bootstrap.
# Without this the visible leg quietly degrades into a second masked leg and
# nothing in this workflow exercises the normal winget bootstrap.
Write-Host '::error::winget is not resolvable on the visible leg; the winget bootstrap is not under test'
exit 1
}
foreach ($scope in 'Machine','User') {
Write-Host ("{0} PATH after scrub: {1}" -f $scope, [System.Environment]::GetEnvironmentVariable('Path', $scope))
}
# Every failure above exits 1 explicitly, so reaching here means the machine
# is clean. Be explicit rather than leaving the runner's appended
# `exit $LASTEXITCODE` to decide.
# Every failure above exits 1 explicitly, so reaching here means clean. Be
# explicit rather than let the runner's appended `exit $LASTEXITCODE` decide.
exit 0
- name: Install
@ -883,10 +880,10 @@ jobs:
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`
# has a meaning here too. Running the checked-out one regardless made a
# dispatch that asked about unsloth.ai report on this ref instead. Empty on
# pull_request/push, so automatic runs stay on this ref.
# 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 `
@ -898,8 +895,8 @@ jobs:
}
# No -SkipTorch: install.ps1 has no param block and its parser matches
# `--no-torch` only (112-142), so the token was silently dropped and every
# Windows leg installed torch anyway. The assert below needs torch, so ask
# for it explicitly rather than by accident.
# Windows leg installed torch anyway. The assert below needs torch, so get it
# on purpose rather than by accident.
& $script *>&1 | Tee-Object -FilePath logs/install.log
$rc = $LASTEXITCODE
Write-Host "installer exit code: $rc"
@ -923,10 +920,10 @@ jobs:
# HONESTY NOTE: the hosted image ships the VC++ 2015-2022 runtime in System32
# and it cannot be removed without breaking the runner, so `import torch`
# succeeding here does NOT prove a genuinely clean no-winget machine has the
# runtime: Test-VCRedistInstalled (studio/setup.ps1:875) finds the
# preinstalled DLL and Ensure-VCRedist (891) short-circuits before it needs
# winget. Record that, then assert what CAN fail: torch imports, and the
# masked leg really did take the no-winget path.
# runtime: Test-VCRedistInstalled (setup.ps1:875) finds the preinstalled DLL
# and Ensure-VCRedist (891) short-circuits before it needs winget. Record that,
# then assert what CAN fail: torch imports, and the masked leg really did take
# the no-winget path.
$sys32 = Join-Path $env:WINDIR 'System32\vcruntime140_1.dll'
Write-Host "preinstalled System32 vcruntime140_1.dll: $(Test-Path $sys32)"
$py = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\python.exe'

View file

@ -11,17 +11,16 @@
#
# Hosted runners have no interactive desktop session, so "runs" means: the bundle
# installs / mounts / extracts, the binary is present, of the right architecture, and
# passes the gatekeeper checks a user would hit (macOS quarantine + codesign, Windows
# installer exit), the process STAYS UP past its preflight (where an unhappy app dies),
# and it writes tauri.log showing the preflight disposition -- the field that read
# clears the gatekeeper checks a user would hit (macOS quarantine + codesign, Windows
# installer exit); the process STAYS UP past its preflight (where an unhappy app dies);
# and it writes tauri.log with a preflight disposition -- the field that read
# `ManagedReady` over an unbootable venv in the bug report. Linux gets the strongest
# check: a real webview under Xvfb.
name: Desktop app clean machine
on:
# Also on PRs touching this job or the machine-stripping scripts: workflow_dispatch
# alone cannot validate a change to the job itself, because dispatch resolves the
# Also on PRs touching this job or the stripping scripts: dispatch resolves the
# workflow from the DEFAULT branch, so a new or edited file on a feature branch can
# never be dispatched and would first run only after merging blind.
pull_request:
@ -55,14 +54,13 @@ permissions:
contents: read
env:
# release-desktop.yml publishes into github.repository, so a nightly aimed
# anywhere else goes green over a broken production bundle.
# unsloth-test/unsloth-test holds one frozen release, so the schedule was
# re-testing the same fixture forever.
# release-desktop.yml publishes into github.repository, so a nightly aimed anywhere
# else goes green over a broken production bundle. unsloth-test/unsloth-test holds
# one frozen release, so the schedule was re-testing the same fixture forever.
REL_REPO: ${{ inputs.release_repo || github.repository }}
# Empty unless dispatched: a pinned tag is an immutable fixture, so a nightly
# against it could never catch a newly published broken bundle. Each download step
# resolves the newest desktop-v* release when this is empty.
# Empty unless dispatched: a pinned tag is an immutable fixture, so a nightly against
# it could never catch a newly published broken bundle. Each download step resolves
# the newest desktop-v* release when this is empty.
REL_TAG: ${{ inputs.release_tag || '' }}
UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
@ -108,10 +106,10 @@ jobs:
ls -la dl
- name: Strip the developer toolchain
# `inputs` exists only for workflow_dispatch, so on pull_request and schedule
# `inputs.strip_toolchain` is '' -- and loose equality coerces both '' and
# false to 0, making `!= false` FALSE, so automatic runs would keep the hosted
# toolchain this workflow exists to remove. Gate on the event instead.
# `inputs` exists only for workflow_dispatch, so elsewhere strip_toolchain is
# '' -- and loose equality coerces both '' and false to 0, making `!= false`
# FALSE, so automatic runs would keep the very toolchain this removes. Gate on
# the event instead.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
run: |
bash .github/scripts/clean-machine-env.sh mask --remove
@ -138,9 +136,8 @@ jobs:
BIN="$APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist")"
file "$BIN"
# `lipo -archs` prints and exits 0 for a thin x86_64 binary, and `|| true`
# swallowed even that, so "the right architecture" was never asserted. lipo
# is an xcrun shim too, absent once the strip step moved CommandLineTools
# aside; /usr/bin/file is base system.
# swallowed even that, so architecture was never asserted. lipo is an xcrun
# shim, gone once the strip moved CommandLineTools aside; file is base system.
ARCHS="$(lipo -archs "$BIN" 2>/dev/null || true)"
[ -n "$ARCHS" ] || ARCHS="$(file -b "$BIN")"
echo "architectures: $ARCHS"
@ -153,9 +150,9 @@ jobs:
codesign -dv --verbose=2 "$APP" 2>&1 | head -20 || true
spctl -a -vvv -t install "$APP" 2>&1 | head -5 || \
echo "::warning::Gatekeeper assessment failed -- users see 'cannot be opened' unless notarised"
# The bundled installer is what actually failed for users. `::error::` is
# only an annotation and `echo` exits 0, so the old `|| echo` form let a
# bundle with no installer pass.
# The bundled installer is what actually failed for users, and `::error::` is
# only an annotation that `echo` exits 0 from, so `|| echo` let a bundle with
# no installer pass.
if [ -f "$APP/Contents/Resources/install.sh" ]; then
echo "bundled install.sh present"
else
@ -169,21 +166,19 @@ jobs:
set -o pipefail
APP="$(ls -d /Applications/*Unsloth*.app | head -1)"
# A headless runner never clicks Install: preflight sets `not_installed` and
# returns (studio/frontend/src/hooks/use-tauri-backend.ts:252-254) while
# startup-screen.tsx:388-389 waits for the button, so launching alone would
# sit on that screen for 90s and pass without ever running the bundled
# installer. Invoke it as studio/src-tauri/src/install.rs does: --tauri,
# stdin closed, no tty. --tauri rejects a custom studio home
# (install.sh:102-114), so drop the workspace-scoped override.
# returns (use-tauri-backend.ts:252-254) while startup-screen.tsx:388-389
# waits for the button, so launching alone sits on that screen for 90s and
# passes without ever running the bundled installer. Invoke it as
# src-tauri/src/install.rs does: --tauri, stdin closed, no tty. --tauri
# rejects a custom studio home (install.sh:102-114), so drop the override.
env -u UNSLOTH_STUDIO_HOME \
bash "$APP/Contents/Resources/install.sh" --tauri \
< /dev/null 2>&1 | tee logs/bundled-install.log
PY="$HOME/.unsloth/studio/unsloth_studio/bin/python"
[ -x "$PY" ] || { echo "::error::bundled installer left no venv at $PY"; exit 1; }
"$PY" -V
# install.rs passes only --tauri, so torch is part of first launch:
# asserting it stops the venv check passing a bundle whose only failure is
# the torch install.
# install.rs passes only --tauri, so torch is part of first launch: without
# this the venv check passes a bundle whose only failure is the torch install.
"$PY" -c "import torch; print('torch', torch.__version__)"
- name: Launch and prove it stays up
@ -221,14 +216,13 @@ jobs:
found=1
if grep -qE "desktop_preflight completed disposition=" "$f"; then disposition=1; fi
done
# Everything above is `|| true`, so this step could not fail while the
# header sells the tauri.log disposition as an acceptance criterion.
# setup_logging (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally
# at process start, so no log at all means the binary never got that far.
# Everything above is `|| true`, so this step could not fail while the header
# sells the tauri.log disposition as an acceptance criterion. setup_logging
# (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally at process
# start, so no log means the binary never got that far, and the disposition
# line is the field the bug report turned on: a process that hangs before
# preflight must not pass.
[ "${found:-0}" = "1" ] || { echo "::error::the app wrote no tauri.log; it never reached setup_logging"; exit 1; }
# setup_logging opens tauri.log at process start, so its existence is implied
# by the launch step. The disposition line is the field the bug report turned
# on, so a process that hangs before preflight must not pass.
[ "${disposition:-0}" = "1" ] || { echo "::error::tauri.log records no desktop_preflight disposition; the app never completed preflight"; exit 1; }
- name: Restore the runner
@ -285,9 +279,8 @@ jobs:
- name: Install with NO dev tooling, only runtime libs
run: |
# Deliberately not build-essential/cmake/git: a user installing a .deb has
# none of that. WebKit + Xvfb are runtime requirements of the app, and apt
# pulls the .deb's declared deps -- if that list is wrong, this step catches
# it.
# none of that. Xvfb and WebKit are runtime requirements, and apt pulls the
# .deb's declared deps, so a wrong dependency list fails here.
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends xvfb
if [ "${{ matrix.kind }}" = "deb" ]; then
@ -305,16 +298,27 @@ jobs:
echo "BIN=$BIN" >> "$GITHUB_ENV"
echo "binary: $BIN"
- name: Strip the developer toolchain
# Same gate as macOS. Without this the Linux rows ignored strip_toolchain
# entirely and ran the bundled installer with the runner's git, gcc, cmake and
# make in /usr/bin, so a bundle that needs a toolchain passed the one workflow
# whose premise is that it must not. After apt: the .deb install needs dpkg.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
run: |
bash .github/scripts/clean-machine-env.sh mask --remove
set -a; . ./clean-machine.env; set +a
bash .github/scripts/clean-machine-assert.sh absent
- name: Run the bundled installer, the path first launch takes
run: |
set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a
set -o pipefail
# The launch step below only proves the process stayed alive: on a fresh
# home preflight reports not_installed and the app sits on the install
# screen waiting for a click (use-tauri-backend.ts:252-254,
# startup-screen.tsx:388-389), so a bundle whose embedded install.sh is
# missing or broken passed both Linux rows. tauri.conf.json:56-59 ships
# install.sh as a bundle resource, so find it where the bundle put it and
# run it as install.rs does.
# The launch step below only proves the process stayed alive: on a fresh home
# preflight reports not_installed and the app waits on the install screen for
# a click (use-tauri-backend.ts:252-254, startup-screen.tsx:388-389), so a
# bundle whose embedded install.sh was missing or broken passed both Linux
# rows. tauri.conf.json:56-59 ships it as a bundle resource, so find it there
# and run it as install.rs does.
if [ "${{ matrix.kind }}" = "deb" ]; then
SH="$(dpkg -L "$(dpkg-deb -f dl/*.deb Package)" | grep -E '/install\.sh$' | head -1)"
else
@ -337,6 +341,7 @@ jobs:
- name: Launch under Xvfb and prove it stays up
run: |
set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a
# Linux is the one platform where a hosted runner can give the app a real
# display, so this is the strongest "does the UI come up" check available
# without self-hosted hardware.
@ -367,16 +372,19 @@ jobs:
found=1
if grep -qE "desktop_preflight completed disposition=" "$f"; then disposition=1; fi
done
# Same acceptance criterion the macOS rows already enforce. Everything
# above is `|| true` and the loop skips a missing log outright, so
# without these two lines the step could not fail. setup_logging
# (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally at
# process start, so no log at all means the binary never got that far,
# and the launch step only proves the process stayed alive: an app that
# hangs before preflight completes would otherwise pass both Linux rows.
# Same acceptance criterion the macOS rows enforce. Everything above is
# `|| true` and the loop skips a missing log, so without these two lines the
# step could not fail. setup_logging (src-tauri/src/main.rs:50-67) opens
# tauri.log at process start, so no log means the binary never got that far,
# and the launch step only proves liveness: an app hanging before preflight
# completes would otherwise pass both Linux rows.
[ "${found:-0}" = "1" ] || { echo "::error::the app wrote no tauri.log; it never reached setup_logging"; exit 1; }
[ "${disposition:-0}" = "1" ] || { echo "::error::tauri.log records no desktop_preflight disposition; the app never completed preflight"; exit 1; }
- name: Restore the runner
if: always()
run: bash .clean-machine/restore.sh || true
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@ -390,8 +398,8 @@ jobs:
windows:
name: desktop windows
runs-on: windows-latest
# 60, not 45: this job now runs the bundled installer, and a full torch install
# on a Windows runner is the slowest of the three platforms.
# 60, not 45: this job runs the bundled installer, and a full torch install on a
# Windows runner is the slowest of the three platforms.
timeout-minutes: 60
steps:
@ -419,7 +427,7 @@ jobs:
gh release download "$REL_TAG" --repo "$REL_REPO" --pattern '*setup.exe' --dir dl
ls -la dl
- name: Strip developer tooling from PATH
- name: Strip the developer toolchain
# `inputs` exists only for workflow_dispatch, so on pull_request and schedule
# `inputs.strip_toolchain` is '' -- and loose equality coerces both '' and
# false to 0, making `!= false` FALSE, so automatic runs would keep the hosted
@ -429,10 +437,59 @@ jobs:
run: |
$drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\', 'CMake',
'Microsoft Visual Studio', 'BuildTools', 'LLVM', 'MSYS', 'mingw')
$kept = ($env:PATH -split ';') | Where-Object {
$p = $_; $p -and -not ($drop | Where-Object { $p -like "*$_*" })
$scrub = {
param($entries)
,@($entries | Where-Object { $p = $_; $p -and -not ($drop | Where-Object { $p -like "*$_*" }) })
}
"PATH=$($kept -join ';')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"PATH=$((& $scrub ($env:PATH -split ';')) -join ';')" |
Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# 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.
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 job would not be clean"; exit 1 }
}
}
# The bundled install.ps1 this job runs calls Refresh-SessionPath (318-337),
# which rebuilds $env:Path from the Machine and User registry values, so a
# process-only scrub lasts until the first refresh and Git/CMake/VS/LLVM come
# back. The runner is ephemeral, so rewrite the registry copies too. Expand
# first: SetEnvironmentVariable rewrites REG_EXPAND_SZ as REG_SZ
# (dotnet/runtime#1442).
foreach ($scope in 'Machine','User') {
$raw = [System.Environment]::GetEnvironmentVariable('Path', $scope)
if ([string]::IsNullOrWhiteSpace($raw)) { continue }
$expanded = [System.Environment]::ExpandEnvironmentVariables($raw) -split ';'
try {
[System.Environment]::SetEnvironmentVariable('Path', ((& $scrub $expanded) -join ';'), $scope)
} catch {
Write-Host "::error::could not scrub the $scope PATH ($($_.Exception.Message)); the strip 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
}
# Prove it: the launcher binary stays, but an interpreter it can still START
# is a leak, because Find-CompatiblePython (install.ps1:1130-1153) probes `py`
# first. `py -0p` is only the launcher's REGISTRY view, which still names the
# paths the rename removed, so a start attempt is the only real evidence.
if (Get-Command py -ErrorAction SilentlyContinue) {
foreach ($v in '-3.11', '-3.12', '-3.13') {
$out = & py $v -c "import sys; print(sys.executable)" 2>&1
$rc = $LASTEXITCODE
Write-Host ("py {0} -> exit {1}: {2}" -f $v, $rc, (($out | Out-String).Trim() -replace '\r?\n', ' / '))
if ($rc -eq 0) { Write-Host "::error::toolcache python survived the mask: $out"; exit 1 }
}
# A failing probe is the outcome we want, but it leaves $LASTEXITCODE
# non-zero and the runner appends `exit $LASTEXITCODE` to every pwsh step
# (actions/runner#351), so the step would fail on a machine that is clean.
$global:LASTEXITCODE = 0
}
exit 0
- name: Silent install
shell: pwsh
@ -454,13 +511,12 @@ jobs:
shell: pwsh
run: |
# The launch step below only proves the process stayed alive. On a fresh
# profile preflight reports not_installed and the app waits for the user to
# click Install (use-tauri-backend.ts:252-254, startup-screen.tsx:388-389),
# so this job passed on a bundle whose embedded install.ps1 was missing or
# broken -- the packaged-app failure the workflow exists to catch, and the
# one thing the macOS and Linux rows now check and Windows did not.
# tauri.conf.json:56-59 ships install.ps1 as a bundle resource, so find it
# where NSIS put it and invoke it as install.rs:326-341 does.
# profile preflight reports not_installed and the app waits for a click on
# Install (use-tauri-backend.ts:252-254, startup-screen.tsx:388-389), so this
# job passed on a bundle whose embedded install.ps1 was missing or broken --
# the packaged-app failure the workflow exists to catch.
# tauri.conf.json:56-59 ships it as a bundle resource, so find it where NSIS
# put it and invoke it as install.rs:326-341 does.
$root = Split-Path -Parent $env:APP_EXE
$ps1 = Get-ChildItem -Path $root -Recurse -Filter 'install.ps1' -ErrorAction SilentlyContinue |
Select-Object -First 1
@ -470,7 +526,7 @@ jobs:
}
Write-Host "bundled installer: $($ps1.FullName)"
# --tauri rejects a custom studio home (install.ps1:189-215), so drop the
# workspace-scoped override the same way install.rs scrubs it (354-357).
# override as install.rs does (354-357).
Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue
& powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass `
-File $ps1.FullName --tauri *>&1 | Tee-Object -FilePath logs/bundled-install.log
@ -487,8 +543,8 @@ jobs:
exit 1
}
& $py -V
# install.rs passes only --tauri, so torch is part of first launch, and a
# venv that cannot import it is the unbootable environment from the report.
# install.rs passes only --tauri, so torch is part of first launch, and a venv
# that cannot import it is the unbootable environment from the report.
& $py -c "import torch; print('torch', torch.__version__)"
if ($LASTEXITCODE -ne 0) {
Write-Host '::error::the bundled install produced a venv with no working torch'
@ -514,6 +570,7 @@ jobs:
if: always()
shell: pwsh
run: |
$found = $false; $disposition = $false
foreach ($f in @("$env:UNSLOTH_STUDIO_HOME\tauri.log",
"$env:USERPROFILE\.unsloth\studio\tauri.log")) {
if (Test-Path $f) {
@ -522,8 +579,26 @@ jobs:
Get-Content $f -Tail 60
Select-String -Path $f -Pattern 'disposition=|can_auto_repair=|ModuleNotFoundError' `
-ErrorAction SilentlyContinue
$found = $true
if (Select-String -Path $f -Pattern 'desktop_preflight completed disposition=' `
-SimpleMatch -Quiet) { $disposition = $true }
}
}
# Same acceptance criterion macOS and Linux already enforce. Test-Path,
# Get-Content and Select-String cannot fail, so without these two lines the
# step was decoration and the 90s liveness check was the whole bar.
# setup_logging (src-tauri/src/main.rs:50-67) opens tauri.log unconditionally
# at process start, so no log means the binary never got that far, and an app
# that hangs before preflight completes would otherwise pass.
if (-not $found) {
Write-Host '::error::the app wrote no tauri.log; it never reached setup_logging'
exit 1
}
if (-not $disposition) {
Write-Host '::error::tauri.log records no desktop_preflight disposition; the app never completed preflight'
exit 1
}
exit 0
- name: Upload logs
if: always()