CI: prove the installer works on a machine with no developer toolchain

No job has ever run the installer on a machine without one.
studio-mac-install-matrix.yml is the only macOS installer job and it runs
'bash install.sh --local --no-torch' on runners that already have the Xcode CLT
selected and setup-python preinstalled, so the CLT gate never fires there, and
--local is precisely the mode that legitimately needs git. Repo-wide there was
zero coverage of xcode-select or CommandLineTools outside install.sh itself.

clean-machine-install-ci.yml runs the installer on a genuinely stripped machine.
macOS legs move /var/db/xcode_select_link, /Library/Developer/CommandLineTools,
/Applications/Xcode*.app and Homebrew aside, so xcode-select -p, git, cc and
clang really do fail, and restore unconditionally afterwards. Removing the
select-link alone is not enough: xcode-select falls through to a full Xcode.app
and re-arms /usr/bin/git. Linux legs use containers, which are genuinely clean.
Windows legs cover winget visible and masked, plus windows-11-arm. A WSL leg
covers the 126 lines of WSL-specific install.sh logic that had no runtime test.

Each macOS leg runs four deliveries: pipe (the advertised command, and the shape
that turns an early exit into curl (56)), file (separates installer logic from
pipe delivery), no-torch, and tauri (stdin closed, no tty, as the desktop app
invokes it). One leg records every toolchain invocation and asserts the trace,
which is the real deliverable: proof the installer never reached for a compiler
rather than proof it happened to succeed.

The asserts test that tools do NOT WORK rather than that they are absent from
PATH. On a real virgin Mac /usr/bin/git and /usr/bin/cc exist as CLT stubs, so
'command -v git' succeeds and only running it tells the truth.

desktop-app-clean-machine-ci.yml installs and launches the SHIPPED desktop app
release on a stripped machine, covering Gatekeeper and quarantine on macOS, NSIS
silent install on Windows, and Xvfb with WebKit2GTK on Linux.

Known limit, stated plainly: hosted macOS runners are developer machines. Masking
reproduces this bug and proves the installer does not invoke a toolchain, but it
cannot prove no hidden dependency exists on a truly virgin Mac. An ephemeral-VM
lane is the follow-up.
This commit is contained in:
Daniel Han 2026-07-28 12:07:33 +00:00
commit c5b4f5ee86
4 changed files with 1174 additions and 0 deletions

View file

@ -0,0 +1,528 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Proves Unsloth installs on a machine that has never seen a developer toolchain.
#
# Why this exists: studio-mac-install-matrix.yml runs `install.sh --local --no-torch`
# on runners that already have Xcode CLT selected AND actions/setup-python
# preinstalled, so the macOS dependency gate in install.sh never fires there -- and
# `--local` is precisely the mode that legitimately needs git. A brand-new Mac
# therefore hits a hard `exit 1` that no CI job covered.
#
# Hosted runners are developer machines, so each job simulates absence rather than
# being virgin. Two modes, because they answer different questions and cannot be
# done by the same mechanism (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.
name: Clean machine install
on:
pull_request:
paths:
- 'install.sh'
- 'install.ps1'
- 'studio/setup.sh'
- 'studio/setup.ps1'
- 'studio/install_python_stack.py'
- '.github/scripts/clean-machine-*.sh'
- '.github/workflows/clean-machine-install-ci.yml'
push:
branches: [main]
paths:
- 'install.sh'
- 'install.ps1'
- '.github/workflows/clean-machine-install-ci.yml'
workflow_dispatch:
inputs:
installer_source:
description: 'published = curl unsloth.ai/install.sh, tree = the checked-out script'
type: choice
options: [tree, published]
default: tree
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
# Keep every install inside the workspace so a leg cannot inherit another's state.
UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
# No wildcard bind -> no ifconfig.me / check-host.net calls on the startup path.
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
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 rather than 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:
# The reported failure, in the shape users run it. Default install (torch
# included) because that is what a consumer actually gets.
- {os: macos-14, mode: mask, delivery: pipe, flags: '', experimental: false}
- {os: macos-14, mode: mask, delivery: file, flags: '', experimental: false}
# What the desktop app runs: no tty, stdin closed, TAURI markers on.
- {os: macos-14, mode: mask, delivery: tauri, flags: '', experimental: false}
# Toolchain present but logged: does the installer ever reach for it?
- {os: macos-14, mode: trace, delivery: file, flags: '', experimental: 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 mask the gate under test.
- {os: macos-14, mode: mask, delivery: file, flags: '--no-torch', experimental: true}
# OS-version dimension.
- {os: macos-15, mode: mask, delivery: pipe, flags: '', experimental: false}
- {os: macos-26, mode: mask, delivery: file, flags: '', experimental: true}
# Intel pins python 3.12, not 3.13 -- informational only.
- {os: macos-15-intel, mode: mask, delivery: file, flags: '', experimental: true}
steps:
# checkout FIRST: it needs a working git, which masking then takes away.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# Deliberately no actions/setup-python: install.sh must bring its own
# uv-managed CPython, exactly as it must on a user's machine.
- name: Record the pre-masking toolchain
run: |
{
echo "xcode-select -p : $(xcode-select -p 2>&1 || true)"
echo "git : $(command -v git || echo none)"
echo "brew : $(command -v brew || echo none)"
echo "cmake : $(command -v cmake || echo none)"
echo "python3 : $(command -v python3 || echo none)"
} | tee runner-baseline.txt
- name: Simulate a clean machine (${{ matrix.mode }})
run: |
mkdir -p logs
if [ "${{ matrix.mode }}" = "mask" ]; then
bash .github/scripts/clean-machine-env.sh mask --remove
else
bash .github/scripts/clean-machine-env.sh trace
fi
- name: Verify the simulation actually took effect
if: matrix.mode == 'mask'
run: |
set -a; . ./clean-machine.env; set +a
bash .github/scripts/clean-machine-assert.sh absent
- 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 || '' }}
run: |
set -a; . ./clean-machine.env; set +a
set -o pipefail
rc=0
FLAGS="${{ matrix.flags }}"
case "${{ matrix.delivery }}" in
file)
# Plain file execution: isolates "installer logic broken" from
# "curl-pipe delivery broken".
bash install.sh $FLAGS 2>&1 | tee logs/install.log || rc=$?
;;
pipe)
# The shape users actually run. install.sh is ~150KB of top-level
# statements, so an early `exit` leaves the writer with a closed
# pipe -> `curl: (56)`. Piping a local file reproduces that
# faithfully without depending on unsloth.ai being current.
# On pull_request/push this input is empty, which correctly falls through to
# the checked-out ref -- only an explicit dispatch tests unsloth.ai.
if [ "${{ inputs.installer_source }}" = "published" ]; then
curl -fsSL https://unsloth.ai/install.sh | sh -s -- $FLAGS 2>&1 | tee logs/install.log || rc=$?
else
# `sh -s --` with no further args would pass an empty positional,
# so only add the separator when there are flags to pass.
if [ -n "$FLAGS" ]; then
cat install.sh | sh -s -- $FLAGS 2>&1 | tee logs/install.log || rc=$?
else
cat install.sh | sh 2>&1 | tee logs/install.log || rc=$?
fi
fi
;;
tauri)
# Exactly how the desktop app invokes it: no tty, stdin closed.
# --tauri rejects a custom UNSLOTH_STUDIO_HOME outright (the desktop app
# still uses the legacy ~/.unsloth/studio root), so the workspace-scoped
# value every other leg relies on has to go here or the installer exits
# before it does any work. The runner is ephemeral, so the real home is
# as disposable as the override.
env -u UNSLOTH_STUDIO_HOME \
bash install.sh --tauri $FLAGS < /dev/null 2>&1 | tee logs/install.log || rc=$?
;;
esac
echo "install_rc=$rc" >> "$GITHUB_OUTPUT"
echo "installer exit code: $rc"
# The pipe legs are the ones that expose curl:(56); surface it explicitly
# 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"
- 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"
[ "${{ matrix.mode }}" = "trace" ] && checks="$checks notools"
bash .github/scripts/clean-machine-assert.sh $checks
- name: Assert llama.cpp loads
if: steps.install.outcome == 'success'
run: |
set -a; . ./clean-machine.env; set +a
# The tauri leg cannot honour UNSLOTH_STUDIO_HOME (see the Install step), so
# it installed into the legacy root and that is where to look.
if [ "${{ matrix.delivery }}" = "tauri" ]; then
HOME_DIR="$HOME/.unsloth/studio"
else
HOME_DIR="$UNSLOTH_STUDIO_HOME"
fi
STUDIO_HOME="$HOME_DIR" bash .github/scripts/assert-llama-loads.sh
- name: Restore the runner
if: always()
run: bash .clean-machine/restore.sh || true
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: clean-mac-${{ matrix.os }}-${{ matrix.mode }}-${{ matrix.delivery }}
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`, which is dash: `set -o pipefail` is an
# "Illegal option" there and kills the step before the installer even starts.
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
include:
# Root + apt available: install.sh's _smart_apt_install should self-heal
# from a base image with no curl, git, gcc or cmake at all.
- label: ubuntu2404-root
image: ubuntu:24.04
runner: ubuntu-latest
experimental: false
- label: ubuntu2404-arm-root
image: ubuntu:24.04
runner: ubuntu-24.04-arm
experimental: false
# 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)`.
- label: ubuntu2404-nonroot
image: ubuntu:24.04
runner: ubuntu-latest
experimental: true
# Non-apt: today this hard-fails at install.sh:2034. Expected failure;
# forces the decision on whether dnf/pacman/zypper get supported.
- label: fedora41
image: fedora:41
runner: ubuntu-latest
experimental: true
steps:
- name: Describe the container's starting state
run: |
for t in curl wget git gcc cc cmake make python3 sudo; do
printf '%-8s %s\n' "$t" "$(command -v $t 2>/dev/null || echo ABSENT)"
done | tee /tmp/container-baseline.txt
# The advertised `curl | sh` cannot even start on an image without curl, so
# the bootstrap transport is provisioned separately from the installer's own
# dependencies. Everything else stays absent.
- name: Provision only the bootstrap transport
run: |
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl
elif command -v dnf >/dev/null 2>&1; then
dnf install -y -q ca-certificates curl
fi
# No actions/checkout here on purpose: it requires git, and a container with git
# preinstalled is not the clean machine under test. Fetch the two files we need
# over the transport provisioned above -- and fetch the INSTALLER from the same
# ref, so these legs can validate a fix instead of only the published script.
- name: Fetch installer + assert script for this ref
run: |
mkdir -p logs .github/scripts
raw="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${GITHUB_SHA}"
curl -fsSL "$raw/.github/scripts/clean-machine-assert.sh" -o .github/scripts/clean-machine-assert.sh
# On pull_request/push this input is empty, which correctly falls through to
# the checked-out ref -- only an explicit dispatch tests unsloth.ai.
if [ "${{ inputs.installer_source }}" = "published" ]; then
curl -fsSL https://unsloth.ai/install.sh -o install.sh
echo "installer: published (unsloth.ai)"
else
curl -fsSL "$raw/install.sh" -o install.sh
echo "installer: this ref (${GITHUB_SHA})"
fi
wc -l install.sh
- name: Create an unprivileged user
if: matrix.label == 'ubuntu2404-nonroot'
run: |
useradd -m tester
# No sudo installed and not root -> exercises the "cannot elevate" branch.
chown -R tester logs install.sh
- name: Install (root)
if: matrix.label != 'ubuntu2404-nonroot'
run: |
set -o pipefail
rc=0
# Piped, because that is the advertised command and the shape that turns an
# early exit into curl:(56).
cat install.sh | sh 2>&1 | tee logs/install.log || rc=$?
echo "installer exit code: $rc"
exit "$rc"
- name: Install (unprivileged, expected to fail cleanly)
if: matrix.label == 'ubuntu2404-nonroot'
run: |
set -o pipefail
rc=0
su tester -c 'cat install.sh | sh' > logs/install.log 2>&1 || rc=$?
echo "installer exit code: $rc"
tail -40 logs/install.log
# It may legitimately fail; what must NOT happen is an unexplained exit or a
# bare broken-pipe error standing in for a real diagnosis.
if [ "$rc" != "0" ] && ! grep -qiE "sudo is not available|apt-get install|missing:|permission" logs/install.log; then
echo "::error::unprivileged install failed with no actionable message"
exit 1
fi
- 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), and none of it
# had ever run in CI -- tests/sh/test_strixhalo_wsl_reroute.sh extracts functions
# and mocks the environment, which cannot catch anything about a real WSL.
#
# No third-party action: the official Ubuntu WSL rootfs plus `wsl --import` is
# deterministic, checksum-verifiable, and avoids adding a supply-chain dependency
# to a repo that audits its lockfiles.
wsl:
name: wsl ubuntu-24.04
runs-on: windows-latest
timeout-minutes: 50
continue-on-error: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Import a fresh Ubuntu 24.04 WSL distro
shell: pwsh
run: |
# WSL2 is present on windows-2022+ runner images; only a distro is missing.
wsl --set-default-version 2
$url = 'https://cloud-images.ubuntu.com/wsl/releases/24.04/current/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, no git, no compiler.
# That is the clean machine, not a simulation of one.
wsl -d unsloth-ci -- sh -c 'for t in curl wget git gcc cmake python3 sudo; do printf "%-8s %s\n" "$t" "$(command -v $t || echo ABSENT)"; done'
- name: Install inside WSL, piped exactly as documented
shell: pwsh
run: |
# Only ca-certificates + curl, because the advertised one-liner cannot even
# start without a transport. Everything else must come from the installer.
wsl -d unsloth-ci -u root -- sh -c 'apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl' 2>&1 | Tee-Object -FilePath logs/wsl-bootstrap.log
# Copy the script in rather than reaching across /mnt/c: a DrvFs path brings
# Windows file permissions and CRLF risk with it, and neither is what a real
# WSL user's install looks like.
$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
# Feed it through a pipe: same shape as `curl ... | sh`, so an early exit
# still exposes the broken-pipe problem, but the script under test is this
# ref rather than whatever production currently serves.
wsl -d unsloth-ci -u root -- sh -c 'cd /root && cat install.sh | sh' 2>&1 | Tee-Object -FilePath logs/wsl-install.log
Write-Host "installer exit: $LASTEXITCODE"
- 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
wsl -d unsloth-ci -u root -- sh -c 'test -x "$HOME/.unsloth/studio/unsloth_studio/bin/unsloth" && "$HOME/.unsloth/studio/unsloth_studio/bin/unsloth" --version || echo "no CLI installed"' 2>&1 |
Tee-Object -FilePath logs/wsl-verify.log
- 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
# The no-winget path (LTSC / Server / managed corporate machines) falls
# back to python.org + astral.sh and is completely untested today. It is
# also the path where Ensure-VCRedist silently does not run, which leaves
# torch unable to load -- hence the explicit `import torch` assert below.
- os: windows-latest
winget: 'masked'
experimental: false
- os: windows-11-arm
winget: 'visible'
experimental: 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')
$kept = ($env:PATH -split ';') | Where-Object {
$p = $_; $p -and -not ($drop | Where-Object { $p -like "*$_*" })
}
if ('${{ matrix.winget }}' -eq 'masked') {
$kept = $kept | Where-Object { $_ -notlike '*WinGet*' -and $_ -notlike '*Microsoft\WindowsApps*' }
}
$newPath = ($kept -join ';')
"PATH=$newPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
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: |
foreach ($t in 'python','git','cmake','cl') {
$f = Get-Command $t -ErrorAction SilentlyContinue
Write-Host ("{0,-8} {1}" -f $t, $(if ($f) { $f.Source } else { 'ABSENT' }))
}
if ('${{ matrix.winget }}' -eq 'masked' -and (Get-Command winget -ErrorAction SilentlyContinue)) {
Write-Host '::error::winget still resolvable; masking failed'
exit 1
}
- name: Install
id: install
shell: pwsh
run: |
$ErrorActionPreference = 'Continue'
& ./install.ps1 -SkipTorch *>&1 | Tee-Object -FilePath logs/install.log
$rc = $LASTEXITCODE
Write-Host "installer exit code: $rc"
exit $rc
- name: Assert torch loads (the VCRedist contract)
if: steps.install.outcome == 'success'
shell: pwsh
run: |
# The prebuilt llama-server and PyTorch both link the VC++ runtime.
# Ensure-VCRedist only runs when winget exists, so on the masked leg this
# is the assertion that catches a silently broken install.
$py = Join-Path $env:UNSLOTH_STUDIO_HOME 'unsloth_studio\Scripts\python.exe'
if (-not (Test-Path $py)) { $py = (Get-Command python -ErrorAction SilentlyContinue).Source }
if (-not $py) { Write-Host '::error::no python from the install'; exit 1 }
& $py -c "import ctypes.util, sys; print('VCRUNTIME140:', ctypes.util.find_library('vcruntime140'))"
& $py -c "import torch; print('torch', torch.__version__)"
if ($LASTEXITCODE -ne 0) { Write-Host '::error::torch failed to import (VC++ runtime missing?)'; exit 1 }
- 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

View file

@ -0,0 +1,354 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Installs and launches the SHIPPED desktop app on a machine stripped of developer
# tooling, on all three platforms.
#
# studio-tauri-smoke.yml compiles the Tauri crate; release-desktop.yml produces the
# bundles. Neither takes a published artifact, puts it on a clean machine, and checks
# that it starts -- which is exactly the gap the reported failures fell through: both
# came from the packaged app running its bundled
# Contents/Resources/install.sh, a path no CI job exercised.
#
# What "runs" means here, given hosted runners have no interactive desktop session:
# - the bundle installs / mounts / extracts
# - the binary is present, of the right architecture, and passes the OS gatekeeper
# checks a user would hit (macOS quarantine + codesign, Windows installer exit)
# - the process starts and STAYS UP past its preflight (it does not exit or crash),
# which is where an unhappy app dies
# - it writes tauri.log, and that log shows the preflight disposition -- the same
# 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 that touch this job or the machine-stripping scripts. workflow_dispatch
# alone is not enough to validate a change to the job itself: dispatch resolves the
# workflow from the DEFAULT branch, so a new or edited file on a feature branch can
# never be dispatched, and the job would first run only after merging blind.
pull_request:
paths:
- '.github/workflows/desktop-app-clean-machine-ci.yml'
- '.github/scripts/clean-machine-env.sh'
- '.github/scripts/clean-machine-assert.sh'
workflow_dispatch:
inputs:
release_tag:
description: 'Release tag in the desktop release repo'
type: string
default: 'desktop-v0.1.50-beta'
release_repo:
description: 'owner/name hosting the desktop release'
type: string
default: 'unsloth-test/unsloth-test'
strip_toolchain:
description: 'Strip developer tooling before installing'
type: boolean
default: true
schedule:
# Nightly, so a broken published bundle is caught without anyone asking.
- cron: '17 5 * * *'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
REL_REPO: ${{ inputs.release_repo || 'unsloth-test/unsloth-test' }}
REL_TAG: ${{ inputs.release_tag || 'desktop-v0.1.50-beta' }}
UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
jobs:
# ── macOS: .dmg, Apple Silicon ────────────────────────────────────────────
macos:
name: desktop macOS ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 45
continue-on-error: ${{ matrix.experimental }}
strategy:
fail-fast: false
matrix:
include:
- {os: macos-14, experimental: false}
- {os: macos-15, experimental: false}
- {os: macos-26, experimental: true}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download the shipped .dmg
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p dl logs
gh release download "$REL_TAG" --repo "$REL_REPO" \
--pattern '*aarch64.dmg' --dir dl
ls -la dl
- name: Strip the developer toolchain
if: ${{ inputs.strip_toolchain != false }}
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: Mount and install
run: |
DMG="$(ls dl/*.dmg | head -1)"
# A real download is quarantined; Gatekeeper treats it differently from a
# locally built bundle, and that difference is a genuine failure mode.
xattr -w com.apple.quarantine \
"0081;$(printf %x $(date +%s));Safari;" "$DMG" 2>/dev/null || true
hdiutil attach "$DMG" -nobrowse -quiet -mountpoint /Volumes/UnslothCI
APP="$(ls -d /Volumes/UnslothCI/*.app | head -1)"
echo "app bundle: $APP"
cp -R "$APP" /Applications/
hdiutil detach /Volumes/UnslothCI -quiet
ls -la /Applications | grep -i unsloth
- name: Inspect the bundle (arch, signature, Gatekeeper)
run: |
APP="$(ls -d /Applications/*Unsloth*.app | head -1)"
BIN="$APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist")"
file "$BIN"
lipo -archs "$BIN" || true
# Report rather than gate: an unnotarised beta is expected to fail
# assessment, but a user WILL hit this, so it must be visible.
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.
test -f "$APP/Contents/Resources/install.sh" \
&& echo "bundled install.sh present" \
|| echo "::error::no bundled install.sh in the app"
- name: Launch and prove it stays up
run: |
set -a; [ -f ./clean-machine.env ] && . ./clean-machine.env; set +a
APP="$(ls -d /Applications/*Unsloth*.app | head -1)"
BIN="$APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist")"
"$BIN" > logs/app-stdout.log 2>&1 &
APP_PID=$!
# 90s: long enough to clear preflight and start the bundled installer.
for i in $(seq 1 90); do
kill -0 "$APP_PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$APP_PID" 2>/dev/null; then
echo "app still running after 90s (pid $APP_PID)"
kill -TERM "$APP_PID" 2>/dev/null || true
else
wait "$APP_PID" 2>/dev/null; rc=$?
echo "::error::desktop app exited early with rc=$rc"
tail -50 logs/app-stdout.log || true
exit 1
fi
- name: What did its own log say?
if: always()
run: |
for f in "$UNSLOTH_STUDIO_HOME/tauri.log" "$HOME/.unsloth/studio/tauri.log"; do
[ -f "$f" ] || continue
echo "=== $f ==="
cp "$f" logs/ 2>/dev/null || true
tail -60 "$f"
# The two fields the bug report turned on.
grep -E "disposition=|can_auto_repair=|Xcode Command Line|ModuleNotFoundError" "$f" || true
done
- name: Restore the runner
if: always()
run: bash .clean-machine/restore.sh || true
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-macos-${{ matrix.os }}
path: logs/
retention-days: 7
if-no-files-found: warn
# ── Linux: .deb and .AppImage, with a real webview under Xvfb ────────────
linux:
name: desktop linux ${{ matrix.kind }}
runs-on: ubuntu-22.04
timeout-minutes: 45
continue-on-error: ${{ matrix.experimental }}
strategy:
fail-fast: false
matrix:
include:
- {kind: deb, experimental: false}
- {kind: appimage, experimental: false}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download the shipped bundle
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p dl logs
pat='*.deb'; [ "${{ matrix.kind }}" = "appimage" ] && pat='*.AppImage'
gh release download "$REL_TAG" --repo "$REL_REPO" --pattern "$pat" --dir dl
ls -la dl
- 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 itself,
# and apt pulls the .deb's declared deps -- if that list is wrong, this
# step is what catches it.
sudo apt-get update -qq
sudo apt-get install -y -qq --no-install-recommends xvfb
if [ "${{ matrix.kind }}" = "deb" ]; then
sudo apt-get install -y ./dl/*.deb || {
echo "::error::the .deb does not declare its runtime dependencies correctly"
exit 1
}
BIN="$(dpkg -L "$(dpkg-deb -f dl/*.deb Package)" | grep -E '/usr/bin/' | head -1)"
else
sudo apt-get install -y -qq --no-install-recommends libfuse2 \
libwebkit2gtk-4.1-0 libgtk-3-0 libayatana-appindicator3-1 || true
chmod +x dl/*.AppImage
BIN="$(ls dl/*.AppImage | head -1)"
fi
echo "BIN=$BIN" >> "$GITHUB_ENV"
echo "binary: $BIN"
- name: Launch under Xvfb and prove it stays up
run: |
# Linux is the one platform where a hosted runner can give the app a real
# display, so this is the strongest "does the UI actually come up" check
# available without self-hosted hardware.
xvfb-run -a --server-args="-screen 0 1440x900x24" \
"$BIN" > logs/app-stdout.log 2>&1 &
APP_PID=$!
for i in $(seq 1 90); do
kill -0 "$APP_PID" 2>/dev/null || break
sleep 1
done
if kill -0 "$APP_PID" 2>/dev/null; then
echo "app still running after 90s"
kill -TERM "$APP_PID" 2>/dev/null || true
else
wait "$APP_PID" 2>/dev/null; rc=$?
echo "::error::desktop app exited early with rc=$rc"
tail -60 logs/app-stdout.log || true
exit 1
fi
- name: What did its own log say?
if: always()
run: |
for f in "$UNSLOTH_STUDIO_HOME/tauri.log" "$HOME/.unsloth/studio/tauri.log"; do
[ -f "$f" ] || continue
echo "=== $f ==="; cp "$f" logs/ 2>/dev/null || true; tail -60 "$f"
grep -E "disposition=|can_auto_repair=|ModuleNotFoundError" "$f" || true
done
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-linux-${{ matrix.kind }}
path: logs/
retention-days: 7
if-no-files-found: warn
# ── Windows: NSIS setup.exe, silent install ──────────────────────────────
windows:
name: desktop windows
runs-on: windows-latest
timeout-minutes: 45
continue-on-error: true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download the shipped installer
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p dl logs
gh release download "$REL_TAG" --repo "$REL_REPO" --pattern '*setup.exe' --dir dl
ls -la dl
- name: Strip developer tooling from PATH
if: ${{ inputs.strip_toolchain != false }}
shell: pwsh
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 "*$_*" })
}
"PATH=$($kept -join ';')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Silent install
shell: pwsh
run: |
$exe = (Get-ChildItem dl/*setup.exe | Select-Object -First 1).FullName
# /S is the NSIS silent switch. A user double-clicks, but an installer that
# cannot run unattended also cannot be scripted or MDM-deployed.
$p = Start-Process -FilePath $exe -ArgumentList '/S' -Wait -PassThru
Write-Host "installer exit: $($p.ExitCode)"
if ($p.ExitCode -ne 0) { Write-Host "::error::silent install failed"; exit 1 }
$found = Get-ChildItem -Path "$env:LOCALAPPDATA","$env:ProgramFiles" -Recurse `
-Filter '*Unsloth*.exe' -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $found) { Write-Host '::error::no installed executable found'; exit 1 }
Write-Host "installed: $($found.FullName)"
"APP_EXE=$($found.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Launch and prove it stays up
shell: pwsh
run: |
$p = Start-Process -FilePath $env:APP_EXE -PassThru `
-RedirectStandardOutput logs/app-stdout.log `
-RedirectStandardError logs/app-stderr.log
for ($i = 0; $i -lt 90; $i++) { if ($p.HasExited) { break }; Start-Sleep -Seconds 1 }
if ($p.HasExited) {
Write-Host "::error::desktop app exited early with rc=$($p.ExitCode)"
Get-Content logs/app-stdout.log, logs/app-stderr.log -Tail 40 -ErrorAction SilentlyContinue
exit 1
}
Write-Host "app still running after 90s"
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
- name: What did its own log say?
if: always()
shell: pwsh
run: |
foreach ($f in @("$env:UNSLOTH_STUDIO_HOME\tauri.log",
"$env:USERPROFILE\.unsloth\studio\tauri.log")) {
if (Test-Path $f) {
Write-Host "=== $f ==="
Copy-Item $f logs/ -ErrorAction SilentlyContinue
Get-Content $f -Tail 60
Select-String -Path $f -Pattern 'disposition=|can_auto_repair=|ModuleNotFoundError' `
-ErrorAction SilentlyContinue
}
}
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-windows
path: logs/
retention-days: 7
if-no-files-found: warn