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.
740 lines
38 KiB
YAML
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
|