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

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

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

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

740 lines
38 KiB
YAML

# 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 and release-desktop.yml produces the
# bundles, but neither puts a published artifact on a clean machine and checks that it
# starts -- the gap the reported failures fell through: both came from the packaged app
# running its bundled Contents/Resources/install.sh, which no CI job exercised.
#
# Hosted runners have no interactive desktop session, so "runs" means: the bundle
# installs, the binary is present, of the right architecture, and clears the gatekeeper
# checks a user hits (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 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:
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: ''
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:
# Drafts are listed only to a token with push access, and every desktop-v* release here
# is a draft, so `contents: read` cannot see the bundle under test at all.
contents: write
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.
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 then resolves
# the newest desktop-v* release, drafts included -- every desktop-v* release here is cut
# as a draft, so --exclude-drafts matched nothing and every leg died resolving.
# releases/tags/<tag> 404s for a draft, but gh looks drafts up over GraphQL, so `gh
# release download <tag>` still fetches their assets.
REL_TAG: ${{ inputs.release_tag || '' }}
UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
jobs:
# ── macOS: .dmg, Apple Silicon ────────────────────────────────────────────
macos:
# A fork PR's token is read-only however this workflow declares permissions, so it
# cannot list the draft releases every desktop-v* bundle is published as. Skip rather
# than fail: a property of the trigger, not a broken release.
if: github.event.pull_request.head.repo.fork != true
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
# Desktop releases are prereleases (never repo-wide "latest") and drafts, so the
# newest desktop-v* tag has to be resolved explicitly. See REL_TAG above.
if [ -z "$REL_TAG" ]; then
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \
--json tagName,createdAt \
--jq '[.[] | select(.tagName | startswith("desktop-v"))]
| sort_by(.createdAt) | reverse | .[0].tagName // empty')"
# Loud on purpose: there is no bundle to test, so passing would prove nothing.
[ -n "$REL_TAG" ] || {
echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)"
exit 1
}
echo "resolved release tag: $REL_TAG"
echo "REL_TAG=$REL_TAG" >> "$GITHUB_ENV"
fi
gh release download "$REL_TAG" --repo "$REL_REPO" \
--pattern '*aarch64.dmg' --dir dl
ls -la dl
- name: Strip the developer toolchain
# `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.
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: Mount and install
run: |
DMG="$(ls dl/*.dmg | head -1)"
# A real download is quarantined, and Gatekeeper treats that differently from a
# locally built bundle: 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` prints and exits 0 for a thin x86_64 binary, and `|| true`
# 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"
case "$ARCHS" in
*arm64*|*aarch64*) ;;
*) echo "::error::the aarch64 .dmg carries no arm64 binary ($ARCHS)"; exit 1 ;;
esac
# 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, 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
echo "::error::no bundled install.sh in the app"
exit 1
fi
- 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
APP="$(ls -d /Applications/*Unsloth*.app | head -1)"
# A headless runner never clicks Install: preflight sets `not_installed` and
# returns (use-tauri-backend.ts:252-254) while startup-screen.tsx:388-389 waits
# for the button, so launching alone sits there for 90s 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.
# KNOWN OUTCOME PIN, retire when the desktop release catches up to #7547. REL_TAG
# predates #7547, so the bundle's own install.sh still hard-exits on the Xcode
# CLT gate that #7547 replaced with a warning. Only a new release can move that,
# not this PR. _check_macos_deps is the function #7547 added, so finding it means
# the release caught up and this pin must go.
SH="$APP/Contents/Resources/install.sh"
if grep -q '_check_macos_deps' "$SH"; then
echo "::error::the bundled install.sh now carries #7547; delete this pin block and let the venv + torch assertions below run unconditionally"
exit 1
fi
rc=0
env -u UNSLOTH_STUDIO_HOME \
bash "$SH" --tauri \
< /dev/null 2>&1 | tee logs/bundled-install.log || rc=$?
echo "bundled installer exit code: $rc"
# Exit code AND the exact gate line, so any other non-zero exit still fails.
if [ "$rc" -eq 1 ] && grep -qE '^==> Xcode Command Line Tools are required\.[[:space:]]*$' logs/bundled-install.log; then
echo "::notice::known pre-#7547 outcome: the shipped bundle's install.sh stopped on the Xcode CLT gate and exited 1. Not a regression here; the next desktop release retires this pin."
exit 0
fi
[ "$rc" -eq 0 ] || {
echo "::error::bundled installer exited $rc, which is neither success nor the pinned pre-#7547 outcome (exit 1 plus '==> Xcode Command Line Tools are required.')"
exit 1
}
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: 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
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
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 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; }
[ "${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()
# `|| true` swallowed everything, a genuinely broken restore included. The file
# only exists once the strip step ran, and an earlier step can fail before that,
# so skip explicitly when it is absent and let a real failure surface.
run: |
if [ -f .clean-machine/restore.sh ]; then
bash .clean-machine/restore.sh
else
echo "no .clean-machine/restore.sh: the toolchain was never stripped, nothing to restore"
fi
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
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:
# See the macOS job: a fork PR's token cannot list drafts, so skip rather than fail.
if: github.event.pull_request.head.repo.fork != true
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
# See the macOS job. Loud on purpose: no bundle means nothing to prove.
if [ -z "$REL_TAG" ]; then
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \
--json tagName,createdAt \
--jq '[.[] | select(.tagName | startswith("desktop-v"))]
| sort_by(.createdAt) | reverse | .[0].tagName // empty')"
[ -n "$REL_TAG" ] || {
echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)"
exit 1
}
echo "resolved release tag: $REL_TAG"
echo "REL_TAG=$REL_TAG" >> "$GITHUB_ENV"
fi
pat='*.deb'; [ "${{ matrix.kind }}" = "appimage" ] && pat='*.AppImage'
gh release download "$REL_TAG" --repo "$REL_REPO" --pattern "$pat" --dir dl
ls -la dl
- name: Strip the developer toolchain
# Same gate as macOS: without it the Linux rows ignored strip_toolchain and ran the
# bundled installer with the runner's git, gcc, cmake and make in /usr/bin.
#
# BEFORE the bundle install, as macOS and Windows already do: dpkg runs the
# package's own maintainer scripts, so installing first let them see the hosted
# image's toolchain. Nothing in that install needs a masked tool --
# clean-machine-env.sh moves aside only $TOOLS, leaving the package manager itself
# -- and the current bundle ships a postrm and no install-time script.
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: Install with NO dev tooling, only runtime libs
run: |
# Deliberately not build-essential/cmake/git: a user installing a .deb has 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
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"
# The strip runs before this, but `apt-get install ./dl/*.deb` then pulls the bundle's
# DECLARED dependencies, so a release that adds git, cmake or a compiler to that list
# puts one back in /usr/bin and both required Linux rows still pass. `absent` ran only
# beforehand, so re-run it here, before the bundled installer. The current dependency
# closure is 65 packages of runtime libs and no toolchain, so this is green today and
# only a new dependency can turn it red.
- name: Re-assert the toolchain is still absent after the package install
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
run: |
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 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
# ls returns a bare filename here, and a command word with no slash resolves
# through PATH, not the cwd, so this needs the ./ prefix.
(cd dl && "./$(ls *.AppImage | head -1)" --appimage-extract >/dev/null)
SH="$(find dl/squashfs-root -name install.sh -type f | head -1)"
fi
[ -n "$SH" ] && [ -f "$SH" ] || { echo "::error::the bundle ships no install.sh resource"; exit 1; }
echo "bundled installer: $SH"
# KNOWN OUTCOME PIN, retire when the desktop release catches up to #7547. The
# bundle carries its own install.sh and REL_TAG predates #7547, so on a stripped
# runner it still exits 2 at the NEED_SUDO handshake for the optional set instead
# of falling through to prebuilt llama.cpp. Only a new release can move that, not
# this PR. _SMART_APT_OPTIONAL is the guard #7547 added, so finding it means the
# release caught up and this pin must go.
if grep -q '_SMART_APT_OPTIONAL' "$SH"; then
echo "::error::the bundled install.sh now carries #7547; delete this pin block and let the venv + torch assertions below run unconditionally"
exit 1
fi
# --tauri rejects a custom studio home (install.sh:102-114), so drop the
# workspace-scoped override; close stdin as install.rs does.
rc=0
env -u UNSLOTH_STUDIO_HOME \
bash "$SH" --tauri < /dev/null 2>&1 | tee logs/bundled-install.log || rc=$?
echo "bundled installer exit code: $rc"
# Exit code AND the exact optional set, so a different NEED_SUDO list or any other
# non-zero exit is still a failure.
if [ "$rc" -eq 2 ] && grep -qE '^\[TAURI:NEED_SUDO\] cmake git build-essential libcurl4-openssl-dev[[:space:]]*$' logs/bundled-install.log; then
echo "::notice::known pre-#7547 outcome: the shipped bundle's install.sh asked to elevate for the optional set and exited 2. Not a regression here; the next desktop release retires this pin."
exit 0
fi
[ "$rc" -eq 0 ] || {
echo "::error::bundled installer exited $rc, which is neither success nor the pinned pre-#7547 outcome (exit 2 plus exactly '[TAURI:NEED_SUDO] cmake git build-essential libcurl4-openssl-dev')"
exit 1
}
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.
"$PY" -c "import torch; print('torch', torch.__version__)"
- 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.
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
found=1
if grep -qE "desktop_preflight completed disposition=" "$f"; then disposition=1; fi
done
# Same acceptance criterion the macOS rows enforce, and for the same reason:
# everything above is `|| true` and the loop skips a missing log, so without
# these two lines the step could not fail.
[ "${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()
# See the macOS job: `|| true` would swallow a genuinely broken restore.
run: |
if [ -f .clean-machine/restore.sh ]; then
bash .clean-machine/restore.sh
else
echo "no .clean-machine/restore.sh: the toolchain was never stripped, nothing to restore"
fi
- name: Upload logs
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-linux-${{ matrix.kind }}
path: logs/
retention-days: 7
if-no-files-found: warn
# ── Windows: NSIS setup.exe, silent install ──────────────────────────────
windows:
# See the macOS job: a fork PR's token cannot list drafts, so skip rather than fail.
if: github.event.pull_request.head.repo.fork != true
name: desktop windows
runs-on: windows-latest
# 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:
- 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
# See the macOS job. Loud on purpose: no bundle means nothing to prove.
if [ -z "$REL_TAG" ]; then
REL_TAG="$(gh release list --repo "$REL_REPO" --limit 100 \
--json tagName,createdAt \
--jq '[.[] | select(.tagName | startswith("desktop-v"))]
| sort_by(.createdAt) | reverse | .[0].tagName // empty')"
[ -n "$REL_TAG" ] || {
echo "::error::no desktop-v* release visible in $REL_REPO -- either none has been cut, or this token cannot list drafts (needs contents: write)"
exit 1
}
echo "resolved release tag: $REL_TAG"
echo "REL_TAG=$REL_TAG" >> "$GITHUB_ENV"
fi
gh release download "$REL_TAG" --repo "$REL_REPO" --pattern '*setup.exe' --dir dl
ls -la dl
- name: Strip the developer toolchain
# Same gate as macOS, for the same `inputs`-coercion reason.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
shell: pwsh
run: |
$drop = @('hostedtoolcache\windows\Python', 'WindowsApps', '\Git\', 'CMake',
'Microsoft Visual Studio', 'BuildTools', 'LLVM', 'MSYS', 'mingw')
# winget is an app-execution alias under ...\Local\Microsoft\WindowsApps, so the
# WindowsApps fragment -- there to take the Store's python.exe alias away --
# drops the OS package manager with it. winget is not developer tooling: every
# consumer Windows machine this bundle ships to has it, and the bundled
# install.ps1 reaches for it for the git that studio/setup.ps1:1657-1669 still
# gates on unconditionally. Without it this lane only re-runs, as a hard failure,
# the no-winget fallback clean-machine-install-ci.yml already covers and pins on
# its winget=masked row. Resolve winget before the scrub and hand it back through
# a shim, exactly as that workflow does.
$wingetCmd = Get-Command winget -ErrorAction SilentlyContinue
if (-not $wingetCmd) {
Write-Host '::error::winget was not on PATH before the strip; this image ships it and the bundled installer needs it'
exit 1
}
$shim = Join-Path $env:RUNNER_TEMP 'winget-shim'
New-Item -ItemType Directory -Force -Path $shim | Out-Null
Set-Content -LiteralPath (Join-Path $shim 'winget.cmd') -Encoding ascii `
-Value "@`"$($wingetCmd.Source)`" %*"
$scrub = {
param($entries)
,@($entries | Where-Object { $p = $_; $p -and -not ($drop | Where-Object { $p -like "*$_*" }) })
}
"PATH=$shim;$((& $scrub ($env:PATH -split ';')) -join ';')" |
Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Off disk, not just off PATH: py.exe lives in C:\Windows (which must stay) and
# uv does its own 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
# merges the Machine and User registry PATHs back into $env:Path, so a
# process-only scrub lasts until the first refresh and Git/CMake/VS/LLVM come back
# from the registry. The runner is ephemeral, so rewrite the registry copies too.
# (A merge keeps what the process already had, which is why the winget shim above
# survives.) 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
}
exit 0
- name: Verify the strip took effect
# PATH written to $GITHUB_ENV only applies to LATER steps, so the scrub can only be
# checked from here. The drop list above is heuristic path-fragment matching: if a
# runner image moves any of these tools outside those fragments, the bundled
# install.ps1 reuses the survivor and this job still calls itself clean. Same
# assertion the installer workflow runs, same reason.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.strip_toolchain }}
shell: pwsh
run: |
$leaked = @()
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)" }
}
# `py` itself lives in C:\Windows and stays. Only 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) { $leaked += "py $v -> $out" }
}
# 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 exit 1 on a machine that is clean.
$global:LASTEXITCODE = 0
}
# The shim is the only reason winget resolves after the WindowsApps drop. It
# survives the installer's own refreshes because Refresh-SessionPath
# (install.ps1:318-337) and setup.ps1's Refresh-Environment MERGE the current
# $env:Path back in rather than replace it -- but assert that, or this lane
# silently degrades into the no-winget leg the installer workflow already pins.
$winget = Get-Command winget -ErrorAction SilentlyContinue
Write-Host ("winget {0}" -f $(if ($winget) { $winget.Source } else { 'ABSENT' }))
if (-not $winget) {
Write-Host '::error::winget did not survive the strip; the bundled installer would take the no-winget fallback instead of the consumer path'
exit 1
}
if ($leaked) {
Write-Host "::error::developer tooling survived the strip: $($leaked -join '; ')"
exit 1
}
exit 0
- 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 cannot be scripted or MDM-deployed either.
$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: Run the bundled installer, the path first launch takes
shell: pwsh
run: |
# The launch step below only proves the process stayed alive: on a fresh profile
# 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. 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.
$root = Split-Path -Parent $env:APP_EXE
$ps1 = Get-ChildItem -Path $root -Recurse -Filter 'install.ps1' -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $ps1) {
Write-Host '::error::the bundle ships no install.ps1 resource'
exit 1
}
Write-Host "bundled installer: $($ps1.FullName)"
# --tauri rejects a custom studio home (install.ps1:189-215), so drop the
# 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
$rc = $LASTEXITCODE
Write-Host "bundled installer exit: $rc"
if ($rc -ne 0) {
Write-Host "::error::bundled installer exited $rc"
exit $rc
}
# The exit code alone is not enough: it is the venv the app then boots from.
$py = Join-Path $env:USERPROFILE '.unsloth\studio\unsloth_studio\Scripts\python.exe'
if (-not (Test-Path $py)) {
Write-Host "::error::bundled installer left no venv at $py"
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.
& $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'
exit 1
}
- 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: |
$found = $false; $disposition = $false
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
$found = $true
if (Select-String -Path $f -Pattern 'desktop_preflight completed disposition=' `
-SimpleMatch -Quiet) { $disposition = $true }
}
}
# Same acceptance criterion macOS and Linux enforce: Test-Path, Get-Content and
# Select-String cannot fail, so without these two lines the step was decoration.
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()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-windows
path: logs/
retention-days: 7
if-no-files-found: warn